diff --git a/backend/internal/cli/mysql_backup.go b/backend/internal/cli/mysql_backup.go index 20299e6..4b6a7be 100644 --- a/backend/internal/cli/mysql_backup.go +++ b/backend/internal/cli/mysql_backup.go @@ -323,7 +323,11 @@ func restoreMySQL(ctx context.Context, cfg config.DatabaseConfig, input string) return fmt.Errorf("获取 MySQL 恢复会话失败:%w", err) } defer connection.Close() - for _, statement := range splitMySQLStatements(string(script)) { + statements, err := splitMySQLStatements(string(script)) + if err != nil { + return err + } + for _, statement := range statements { if _, err := connection.ExecContext(ctx, statement); err != nil { return fmt.Errorf("执行 MySQL 恢复语句失败:%w", err) } @@ -331,9 +335,11 @@ func restoreMySQL(ctx context.Context, cfg config.DatabaseConfig, input string) return nil } -func splitMySQLStatements(script string) []string { +func splitMySQLStatements(script string) ([]string, error) { + script = strings.TrimPrefix(script, "\uFEFF") var statements []string var statement strings.Builder + delimiter := ";" var quote byte lineComment := false blockComment := false @@ -392,6 +398,32 @@ func splitMySQLStatements(script string) []string { } continue } + if (index == 0 || script[index-1] == '\n') && strings.TrimSpace(statement.String()) == "" { + end := strings.IndexByte(script[index:], '\n') + if end < 0 { + end = len(script) - index + } + line := strings.TrimSpace(script[index : index+end]) + if len(line) >= len("delimiter") && strings.EqualFold(line[:len("delimiter")], "delimiter") && + (len(line) == len("delimiter") || line[len("delimiter")] == ' ' || line[len("delimiter")] == '\t') { + value := strings.TrimSpace(line[len("delimiter"):]) + if len(value) >= 2 && (value[0] == '\'' || value[0] == '"') && value[len(value)-1] == value[0] { + value = value[1 : len(value)-1] + } + if value == "" || strings.ContainsAny(value, " \t\r\n") || strings.ContainsRune(value, '\\') || strings.ContainsRune(value, '\'') || strings.ContainsRune(value, '"') || strings.ContainsRune(value, '`') { + return nil, fmt.Errorf("MySQL 备份中的 DELIMITER 指令无效") + } + delimiter = value + statement.Reset() + index += end + continue + } + } + if strings.HasPrefix(script[index:], delimiter) { + flush() + index += len(delimiter) - 1 + continue + } if current == '\'' || current == '"' || current == '`' { quote = current statement.WriteByte(current) @@ -418,12 +450,11 @@ func splitMySQLStatements(script string) []string { index++ continue } - if current == ';' { - flush() - continue - } statement.WriteByte(current) } + if quote != 0 || blockComment || executableComment { + return nil, fmt.Errorf("MySQL 备份包含未闭合的字符串、标识符或注释") + } flush() - return statements + return statements, nil } diff --git a/backend/internal/cli/mysql_backup_test.go b/backend/internal/cli/mysql_backup_test.go index 7110deb..51614ec 100644 --- a/backend/internal/cli/mysql_backup_test.go +++ b/backend/internal/cli/mysql_backup_test.go @@ -64,7 +64,10 @@ func TestMySQLColumnSQLLiteral(t *testing.T) { func TestSplitMySQLStatementsKeepsQuotedSemicolons(t *testing.T) { script := "-- 注释\nSET FOREIGN_KEY_CHECKS=0;\nINSERT INTO `items` (`value`) VALUES ('a; b'), ('it''s');\n" - statements := splitMySQLStatements(script) + statements, err := splitMySQLStatements(script) + if err != nil { + t.Fatal(err) + } if len(statements) != 2 { t.Fatalf("SQL 语句拆分数量不正确:%d %#v", len(statements), statements) } @@ -75,7 +78,10 @@ func TestSplitMySQLStatementsKeepsQuotedSemicolons(t *testing.T) { func TestSplitMySQLStatementsKeepsExecutableComments(t *testing.T) { script := "CREATE TABLE `items` (`id` bigint) /*!50100 SET @value = 'a;b' */;\n" - statements := splitMySQLStatements(script) + statements, err := splitMySQLStatements(script) + if err != nil { + t.Fatal(err) + } if len(statements) != 1 { t.Fatalf("带可执行注释的 SQL 语句拆分数量不正确:%d %#v", len(statements), statements) } diff --git a/backend/internal/cli/mysql_programs_integration_test.go b/backend/internal/cli/mysql_programs_integration_test.go new file mode 100644 index 0000000..27438d5 --- /dev/null +++ b/backend/internal/cli/mysql_programs_integration_test.go @@ -0,0 +1,205 @@ +package cli + +import ( + "database/sql" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" + + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" +) + +// 官方导出文件不做改写;用同一隔离账户跨测试库恢复,保留原有定义者。 +func TestMySQLRealOfficialStoredPrograms(t *testing.T) { + server := mysqltest.New(t) + // 仅启用本测试新建实例的事件调度,源事件保持禁用,绝不连接共享服务。 + if _, err := server.Admin().ExecContext(t.Context(), "SET GLOBAL event_scheduler = ON"); err != nil { + t.Fatal(err) + } + source := server.Database(t) + parsed, err := mysql.ParseDSN(source.DSN) + if err != nil { + t.Fatal(err) + } + grantMySQLProgramAccess(t, server, parsed.User, parsed.DBName) + // 独立实例启用二进制日志时,函数创建需要管理权限;不修改全局信任开关。 + if server.IsMySQL8() { + if _, err := server.Admin().ExecContext(t.Context(), "GRANT SUPER ON *.* TO "+mysqlStringLiteral(parsed.User)+"@'127.0.0.1'"); err != nil { + t.Fatal(err) + } + } + if !server.IsMySQL8() { + if _, err := server.Admin().ExecContext(t.Context(), "GRANT SELECT ON mysql.proc TO "+mysqlStringLiteral(parsed.User)+"@'127.0.0.1'"); err != nil { + t.Fatal(err) + } + } + db, err := database.Open(t.Context(), source) + if err != nil { + t.Fatal(err) + } + defer db.Close() + connection, err := db.DB.Conn(t.Context()) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + statements := []string{ + "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'", + "CREATE TABLE interop (id INT PRIMARY KEY, value INT NOT NULL, label VARCHAR(100)) ENGINE=InnoDB", + "INSERT INTO interop VALUES (1, 10, '原始值')", + "CREATE TRIGGER first_trigger BEFORE INSERT ON interop FOR EACH ROW BEGIN SET NEW.value = NEW.value + 2; SET NEW.label = CONCAT(NEW.label, ';第一步'); END", + "CREATE TRIGGER second_trigger BEFORE INSERT ON interop FOR EACH ROW FOLLOWS first_trigger BEGIN SET NEW.value = NEW.value * 3; SET NEW.label = CONCAT(NEW.label, ';第二步'); END", + "CREATE PROCEDURE change_value(IN target_id INT) BEGIN UPDATE interop SET value = value + 5 WHERE id = target_id; UPDATE interop SET label = CONCAT(label, ';过程') WHERE id = target_id; END", + "CREATE FUNCTION format_value(input_value INT) RETURNS VARCHAR(100) DETERMINISTIC NO SQL BEGIN DECLARE result VARCHAR(100); SET result = CONCAT('函数;', input_value); RETURN result; END", + "CREATE EVENT disabled_event ON SCHEDULE AT '2036-01-01 00:00:00' ON COMPLETION PRESERVE DISABLE DO BEGIN UPDATE interop SET value = value + 100; UPDATE interop SET label = '事件;执行'; END", + } + for _, statement := range statements { + if _, err := connection.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + definition := readMySQLProgramDefinitions(t, db.DB) + output := filepath.Join(server.Root(), "official-programs.sql") + server.DumpStoredPrograms(t, source, output) + for _, direction := range []string{"official_control", "official_to_scert"} { + t.Run(direction, func(t *testing.T) { + target := server.Database(t) + targetDSN, err := mysql.ParseDSN(target.DSN) + if err != nil { + t.Fatal(err) + } + grantMySQLProgramAccess(t, server, parsed.User, targetDSN.DBName) + targetDSN.User = parsed.User + target.DSN = targetDSN.FormatDSN() + if direction == "official_control" { + server.Import(t, target, output) + } else if err := restoreMySQL(t.Context(), target, output); err != nil { + t.Fatal(err) + } + checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "10|原始值") + verify, err := database.Open(t.Context(), target) + if err != nil { + t.Fatal(err) + } + defer verify.Close() + if restored := readMySQLProgramDefinitions(t, verify.DB); restored != definition { + t.Fatalf("存储对象的定义、执行顺序、权限上下文或事件状态发生变化:\n源:%s\n恢复:%s", definition, restored) + } + for _, statement := range []string{"INSERT INTO interop VALUES (2, 4, '新行')", "CALL change_value(1)"} { + if _, err := verify.DB.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 2", "18|新行;第一步;第二步") + checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "15|原始值;过程") + checkMySQLInteropValue(t, target, "SELECT format_value(7)", "函数;7") + if _, err := verify.DB.ExecContext(t.Context(), "ALTER EVENT disabled_event ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND ENABLE"); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + var value int + for time.Now().Before(deadline) { + if err := verify.DB.QueryRowContext(t.Context(), "SELECT value FROM interop WHERE id = 1").Scan(&value); err != nil { + t.Fatal(err) + } + if value == 115 { + break + } + time.Sleep(25 * time.Millisecond) + } + checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "115|事件;执行") + checkMySQLInteropValue(t, source, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "10|原始值") + }) + } + t.Run("insufficient_privileges", func(t *testing.T) { + target := server.Database(t) + err := restoreMySQL(t.Context(), target, output) + var serverError *mysql.MySQLError + if !errors.As(err, &serverError) || (serverError.Number != 1142 && serverError.Number != 1227 && serverError.Number != 1419) { + t.Fatalf("权限不足时必须返回原始数据库错误,不能忽略或提升权限:%v", err) + } + parsedTarget, err := mysql.ParseDSN(target.DSN) + if err != nil { + t.Fatal(err) + } + var count int + if err := server.Admin().QueryRowContext(t.Context(), "SELECT COUNT(*) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?", parsedTarget.DBName).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("触发器恢复失败后仍继续恢复了后面的例程") + } + }) + t.Run("malformed_script_is_not_executed", func(t *testing.T) { + target := server.Database(t) + invalid := filepath.Join(server.Root(), "malformed-programs.sql") + if err := os.WriteFile(invalid, []byte("CREATE TABLE should_not_exist (id INT);\nDELIMITER\nSELECT 1;"), 0o600); err != nil { + t.Fatal(err) + } + if err := restoreMySQL(t.Context(), target, invalid); err == nil { + t.Fatal("无效分隔符没有阻止恢复") + } + checkMySQLInteropValue(t, target, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()", "0") + }) +} + +func grantMySQLProgramAccess(t *testing.T, server *mysqltest.Server, user, name string) { + t.Helper() + statement := "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, TRIGGER, CREATE ROUTINE, ALTER ROUTINE, EXECUTE, EVENT ON " + quoteMySQLIdentifier(name) + ".* TO " + mysqlStringLiteral(user) + "@'127.0.0.1'" + if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } +} + +func readMySQLProgramDefinitions(t *testing.T, db *sql.DB) string { + t.Helper() + queries := []string{ + `SELECT JSON_ARRAY(TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE, ACTION_ORDER, + ACTION_STATEMENT, ACTION_ORIENTATION, ACTION_TIMING, SQL_MODE, DEFINER, + CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION) + FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() ORDER BY TRIGGER_NAME`, + `SELECT JSON_ARRAY(ROUTINE_NAME, ROUTINE_TYPE, DATA_TYPE, DTD_IDENTIFIER, ROUTINE_DEFINITION, + IS_DETERMINISTIC, SQL_DATA_ACCESS, SECURITY_TYPE, SQL_MODE, DEFINER, ROUTINE_COMMENT, + CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION) + FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() ORDER BY ROUTINE_NAME`, + `SELECT JSON_ARRAY(SPECIFIC_NAME, ORDINAL_POSITION, PARAMETER_MODE, PARAMETER_NAME, + DATA_TYPE, DTD_IDENTIFIER, CHARACTER_SET_NAME, COLLATION_NAME, ROUTINE_TYPE) + FROM information_schema.PARAMETERS WHERE SPECIFIC_SCHEMA = DATABASE() + ORDER BY SPECIFIC_NAME, ORDINAL_POSITION`, + `SELECT JSON_ARRAY(EVENT_NAME, DEFINER, TIME_ZONE, EVENT_BODY, EVENT_DEFINITION, EVENT_TYPE, + EXECUTE_AT, SQL_MODE, STATUS, ON_COMPLETION, EVENT_COMMENT, + CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION) + FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE() ORDER BY EVENT_NAME`, + } + var definitions []string + for _, query := range queries { + rows, err := db.QueryContext(t.Context(), query) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var definition string + if err := rows.Scan(&definition); err != nil { + t.Fatal(err) + } + definitions = append(definitions, definition) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + } + if len(definitions) != 8 { + t.Fatalf("存储对象及参数数量不完整:得到 %d,期望 8", len(definitions)) + } + return strings.Join(definitions, "\n") +} diff --git a/backend/internal/cli/mysql_statements_test.go b/backend/internal/cli/mysql_statements_test.go new file mode 100644 index 0000000..bb0f232 --- /dev/null +++ b/backend/internal/cli/mysql_statements_test.go @@ -0,0 +1,67 @@ +package cli + +import ( + "reflect" + "testing" +) + +func TestSplitMySQLStatementsDelimiter(t *testing.T) { + for _, item := range []struct { + name string + script string + want []string + }{ + { + name: "procedure", + script: "DELIMITER ;;\nCREATE PROCEDURE p() BEGIN SELECT 1; SELECT '值;;分号'; END;;\nDELIMITER ;\nSELECT 2;", + want: []string{"CREATE PROCEDURE p() BEGIN SELECT 1; SELECT '值;;分号'; END", "SELECT 2"}, + }, + { + name: "quoted_crlf_bom", + script: "\uFEFF-- 注释\r\n dElImItEr '$$'\r\nSELECT '$$', `a$$b`, 'it''s';$$\r\nDELIMITER ;\r\nSELECT 2;", + want: []string{"SELECT '$$', `a$$b`, 'it''s';", "SELECT 2"}, + }, + { + name: "comments", + script: "DELIMITER //\n/* // */ SELECT 1 # //\n//\nDELIMITER ;\nSELECT/**/2;", + want: []string{"SELECT 1", "SELECT 2"}, + }, + { + name: "executable_comments", + script: "DELIMITER ;;\n/*!50003 CREATE*/ /*!50003 TRIGGER t BEFORE INSERT ON x FOR EACH ROW BEGIN SET NEW.x = ';;'; END */;;\nDELIMITER ;", + want: []string{"/*!50003 CREATE*/ /*!50003 TRIGGER t BEFORE INSERT ON x FOR EACH ROW BEGIN SET NEW.x = ';;'; END */"}, + }, + { + name: "keyword_is_not_command", + script: "SELECT delimiter FROM x;\nSELECT 'DELIMITER $$';\n-- DELIMITER $$\nSELECT 2;", + want: []string{"SELECT delimiter FROM x", "SELECT 'DELIMITER $$'", "SELECT 2"}, + }, + } { + t.Run(item.name, func(t *testing.T) { + got, err := splitMySQLStatements(item.script) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, item.want) { + t.Fatalf("分隔符解析不正确:得到 %#v,期望 %#v", got, item.want) + } + }) + } +} + +func TestSplitMySQLStatementsRejectsMalformedScript(t *testing.T) { + for _, script := range []string{ + "SELECT 1;\nDELIMITER\nSELECT 2;", + "SELECT 1;\nDELIMITER a b\nSELECT 2;", + "SELECT 1;\nDELIMITER " + string(rune(92)) + "\nSELECT 2;", + "SELECT 1; SELECT '未闭合", + "SELECT 1; SELECT `未闭合", + "SELECT 1; /* 未闭合", + "SELECT 1; /*!50003 未闭合", + } { + got, err := splitMySQLStatements(script) + if err == nil || got != nil { + t.Fatalf("格式错误时不应返回可执行的部分语句:%#v,%v", got, err) + } + } +} diff --git a/backend/internal/testsupport/mysqltest/tools.go b/backend/internal/testsupport/mysqltest/tools.go index 90c2af2..f5ddd90 100644 --- a/backend/internal/testsupport/mysqltest/tools.go +++ b/backend/internal/testsupport/mysqltest/tools.go @@ -18,16 +18,25 @@ import ( // 官方工具仅连接本夹具创建的隔离实例,密码只传入子进程环境,不进入命令行或文件。 func (f *Server) Dump(t *testing.T, cfg config.DatabaseConfig, output string) { + f.dump(t, cfg, output, nil) +} + +func (f *Server) DumpStoredPrograms(t *testing.T, cfg config.DatabaseConfig, output string) { + f.dump(t, cfg, output, []string{"--routines", "--events"}) +} + +func (f *Server) dump(t *testing.T, cfg config.DatabaseConfig, output string, extra []string) { t.Helper() file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { t.Fatal(err) } defer file.Close() - f.runTool(t, "mysqldump", cfg, []string{ + args := []string{ "--single-transaction", "--no-tablespaces", "--set-gtid-purged=OFF", "--skip-add-locks", "--hex-blob", "--complete-insert", "--skip-comments", "--skip-extended-insert", - }, nil, file) + } + f.runTool(t, "mysqldump", cfg, append(args, extra...), nil, file) if err := file.Close(); err != nil { t.Fatal(err) }