From 8cba0d5fd23dc06e69b8cd68f39c4ec4b190b3d4 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 27 Sep 2026 23:02:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98MySQL=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=E4=B8=8E=E6=81=A2=E5=A4=8D=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=A3=80=E6=9F=A5=E7=82=B9=EF=BC=88=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E5=BE=85=E4=BF=AE=E6=AD=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/cli/mysql_backup.go | 19 +- .../mysql_event_expiry_integration_test.go | 96 ++++++ backend/internal/cli/mysql_event_restore.go | 229 +++++++++++++++ .../internal/cli/mysql_event_restore_test.go | 41 +++ .../mysql_event_safety_integration_test.go | 147 ++++++++++ backend/internal/cli/mysql_events.go | 113 ++++++++ .../cli/mysql_events_integration_test.go | 273 ++++++++++++++++++ backend/internal/cli/mysql_privileges.go | 48 ++- .../internal/testsupport/mysqltest/server.go | 3 +- 9 files changed, 957 insertions(+), 12 deletions(-) create mode 100644 backend/internal/cli/mysql_event_expiry_integration_test.go create mode 100644 backend/internal/cli/mysql_event_restore.go create mode 100644 backend/internal/cli/mysql_event_restore_test.go create mode 100644 backend/internal/cli/mysql_event_safety_integration_test.go create mode 100644 backend/internal/cli/mysql_events.go create mode 100644 backend/internal/cli/mysql_events_integration_test.go diff --git a/backend/internal/cli/mysql_backup.go b/backend/internal/cli/mysql_backup.go index c452bfa..63bb685 100644 --- a/backend/internal/cli/mysql_backup.go +++ b/backend/internal/cli/mysql_backup.go @@ -15,7 +15,7 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/database" ) -// backupMySQL 将 MySQL 的基础表、数据、例程和触发器导出为可在 MySQL 5.7/8.0 导入的 SQL 文件。 +// backupMySQL 将 MySQL 的基础表、数据、例程、触发器和事件导出为可在 MySQL 5.7/8.0 导入的 SQL 文件。 func backupMySQL(ctx context.Context, cfg config.Config, output string) error { resolved, err := resolveMaintenanceDatabase(ctx, cfg.Database, cfg.Paths.Config) if err != nil { @@ -59,6 +59,9 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output if _, err := fmt.Fprintf(file, "-- Scert MySQL 数据备份\n-- 生成时间:%s\n\nSET FOREIGN_KEY_CHECKS=0;\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil { return fmt.Errorf("写入 MySQL 备份头失败:%w", err) } + if _, err := fmt.Fprint(file, "-- 恢复前须进入维护窗口,停止事件调度并等待工作线程退出;恢复后先核验对象及警告,再由管理员恢复调度。\n\n"); err != nil { + return err + } rows, err := tx.QueryContext(ctx, "SELECT TABLE_NAME, ENGINE, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME") if err != nil { @@ -92,6 +95,10 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output if err != nil { return err } + events, err := listMySQLEvents(ctx, tx) + if err != nil { + return err + } for _, tableName := range tableNames { if err := dumpMySQLTable(ctx, tx, file, tableName); err != nil { @@ -109,6 +116,10 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output if _, err := fmt.Fprint(file, "SET FOREIGN_KEY_CHECKS=1;\n"); err != nil { return fmt.Errorf("写入 MySQL 备份尾失败:%w", err) } + // 事件必须排在基础数据、例程、触发器和外键恢复之后。 + if err := dumpMySQLEvents(ctx, tx, file, events); err != nil { + return err + } if err := tx.Commit(); err != nil { return fmt.Errorf("结束 MySQL 一致性备份事务失败:%w", err) } @@ -342,10 +353,16 @@ func restoreMySQL(ctx context.Context, cfg config.DatabaseConfig, input string) if err != nil { return err } + if err := preflightMySQLEventRestore(ctx, connection, statements); err != nil { + return err + } for _, statement := range statements { if _, err := connection.ExecContext(ctx, statement); err != nil { return fmt.Errorf("执行 MySQL 恢复语句失败:%w", err) } + if err := checkMySQLEventRestoreWarnings(ctx, connection, statement); err != nil { + return err + } } return nil } diff --git a/backend/internal/cli/mysql_event_expiry_integration_test.go b/backend/internal/cli/mysql_event_expiry_integration_test.go new file mode 100644 index 0000000..2957400 --- /dev/null +++ b/backend/internal/cli/mysql_event_expiry_integration_test.go @@ -0,0 +1,96 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" +) + +func TestMySQLRealEventExpiry(t *testing.T) { + server := mysqltest.New(t) + if _, err := server.Admin().ExecContext(t.Context(), "SET GLOBAL event_scheduler = OFF"); err != nil { + t.Fatal(err) + } + for _, scenario := range []string{"not_preserved", "enabled_preserved", "disabled_preserved"} { + t.Run(scenario, func(t *testing.T) { + cfg := server.Database(t) + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + options := "NOT PRESERVE ENABLE" + if scenario == "enabled_preserved" { + options = "PRESERVE ENABLE" + } else if scenario == "disabled_preserved" { + options = "PRESERVE DISABLE" + } + if _, err := db.DB.ExecContext(t.Context(), "CREATE EVENT expiry ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 SECOND ON COMPLETION "+options+" DO SELECT 1"); err != nil { + t.Fatal(err) + } + var expired bool + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if err := db.DB.QueryRowContext(t.Context(), "SELECT EXECUTE_AT < CURRENT_TIMESTAMP FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE() AND EVENT_NAME = 'expiry'").Scan(&expired); err != nil { + t.Fatal(err) + } + if expired { + break + } + time.Sleep(25 * time.Millisecond) + } + if !expired { + t.Fatal("未实际观测到事件到期") + } + output := filepath.Join(server.Root(), scenario+"-expiry.sql") + if err := os.WriteFile(output, []byte("existing backup"), 0o600); err != nil { + t.Fatal(err) + } + err = backupMySQLDatabase(t.Context(), cfg, output) + if scenario == "disabled_preserved" { + if err != nil { + t.Fatal(err) + } + if err := restoreMySQL(t.Context(), cfg, output); err != nil { + t.Fatal(err) + } + checkMySQLInteropValue(t, cfg, "SELECT STATUS FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE() AND EVENT_NAME = 'expiry'", "DISABLED") + return + } + if err == nil || !strings.Contains(err.Error(), "已到期") { + t.Fatalf("过期事件无法原样重建时应拒绝备份:%v", err) + } + data, err := os.ReadFile(output) + if err != nil || string(data) != "existing backup" { + t.Fatal("事件导出失败破坏了已有备份") + } + if _, err := os.Stat(output + ".tmp"); !os.IsNotExist(err) { + t.Fatal("失败后遗留临时备份") + } + checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE()", "1") + }) + } + for _, scenario := range []string{"dropped_after_create", "disabled_after_create"} { + t.Run(scenario, func(t *testing.T) { + cfg := server.Database(t) + completion := "NOT PRESERVE" + if scenario == "disabled_after_create" { + completion = "PRESERVE" + } + input := filepath.Join(server.Root(), scenario+".sql") + script := "CREATE EVENT expired ON SCHEDULE AT '2001-01-01 00:00:00' ON COMPLETION " + completion + " ENABLE DO SELECT 1;\nCREATE TABLE must_not_execute (id INT);" + if err := os.WriteFile(input, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + if err := restoreMySQL(t.Context(), cfg, input); err == nil || !strings.Contains(err.Error(), "未保留原定义或状态") { + t.Fatalf("事件被服务器删除或禁用时不能报告成功:%v", err) + } + checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()", "0") + }) + } +} diff --git a/backend/internal/cli/mysql_event_restore.go b/backend/internal/cli/mysql_event_restore.go new file mode 100644 index 0000000..f3fab86 --- /dev/null +++ b/backend/internal/cli/mysql_event_restore.go @@ -0,0 +1,229 @@ +package cli + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// 预检必须先于任何恢复语句;不会擅自关闭实例调度器或修改已有事件状态。 +// 管理员仍须维持维护窗口,禁止外部会话在预检后开启调度或修改事件。 +func preflightMySQLEventRestore(ctx context.Context, conn *sql.Conn, statements []string) error { + active := false + for _, statement := range statements { + // 两种字符串转义模式中任一种无法证明安全,就要求停止调度。 + for _, noBackslash := range []bool{false, true} { + tokens := mysqlEventTokens(statement, noBackslash) + if len(tokens) > 0 && tokens[0] == "SET" { + for _, token := range tokens[1:] { + if token == "EVENT_SCHEDULER" { + return fmt.Errorf("MySQL 恢复文件不能修改事件调度器,请由管理员在维护窗口单独管理") + } + } + } + active = active || mysqlStatementMayEnableEvent(tokens) + } + } + tx, err := conn.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return err + } + defer tx.Rollback() + var version, scheduler string + if err := tx.QueryRowContext(ctx, "SELECT VERSION(), @@GLOBAL.event_scheduler").Scan(&version, &scheduler); err != nil { + return fmt.Errorf("读取 MySQL 事件调度状态失败:%w", err) + } + grants, err := readMySQLEffectiveGrants(ctx, tx, version) + if err != nil { + return err + } + process := false + for _, grant := range grants { + match := mysqlPrivilegeGrant.FindStringSubmatch(grant) + if len(match) != 0 && match[1] == "GRANT" && match[3] == "*" && match[4] == "*" && mysqlHasPrivilege(match[2], "PROCESS") { + process = true + } + } + if !process { + return fmt.Errorf("MySQL 恢复需要 PROCESS 权限以确认事件工作线程已退出,不能把不可见线程当成没有运行") + } + var workers int + // 事件工作线程以定义者身份运行,不能按 event_scheduler 用户名筛选。 + // Connect 也可能包含正在建立的普通连接;维护窗口内保守拒绝,不冒险误判为空。 + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.PROCESSLIST WHERE COMMAND = 'Connect'").Scan(&workers); err != nil { + return fmt.Errorf("读取 MySQL 事件工作线程失败:%w", err) + } + if workers != 0 { + return fmt.Errorf("MySQL 仍有事件工作线程运行,请等待其退出后再恢复;不会自动终止事件") + } + if strings.EqualFold(scheduler, "ON") { + if err := requireMySQLEventVisibility(ctx, tx); err != nil { + return err + } + var enabled int + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE() AND STATUS = 'ENABLED'").Scan(&enabled); err != nil { + return err + } + if active || enabled != 0 { + return fmt.Errorf("MySQL 事件调度器仍为 ON,备份或目标库含启用事件;请管理员在维护窗口停止调度并等待事件工作线程退出后重试") + } + } + return nil +} + +func mysqlStatementMayEnableEvent(tokens []string) bool { + _, active := mysqlEventStatement(tokens) + return active +} + +func mysqlEventStatement(tokens []string) (bool, bool) { + if len(tokens) < 2 || (tokens[0] != "CREATE" && tokens[0] != "ALTER") { + return false, false + } + i := 1 + // 只识别语句开头的对象类型,不把例程正文、注释或标识符中的 EVENT 当成事件。 + if tokens[i] == "DEFINER" { + i += 3 // 跳过 DEFINER、等号及账户名称。 + if i < len(tokens) && tokens[i] == "@" { + i += 2 + } else if i+1 < len(tokens) && tokens[i] == "(" && tokens[i+1] == ")" { + i += 2 + } + } + if i >= len(tokens) || tokens[i] != "EVENT" { + return false, false + } + i++ + if i+2 < len(tokens) && tokens[i] == "IF" && tokens[i+1] == "NOT" && tokens[i+2] == "EXISTS" { + i += 3 + } + // EVENT、ENABLE、DISABLE 等可能作为未引用的名称,不能把名称当成状态。 + i = mysqlSkipEventName(tokens, i) + disabled, depth := false, 0 + for ; i < len(tokens); i++ { + token := tokens[i] + switch token { + case "(": + depth++ + case ")": + depth-- + default: + if depth != 0 { + continue + } + switch token { + case "RENAME": + if i+1 < len(tokens) && tokens[i+1] == "TO" { + i = mysqlSkipEventName(tokens, i+2) - 1 + } + case "DO": + return true, !disabled + case "DISABLE": + disabled = true + case "ENABLE": + disabled = false + } + } + } + return true, !disabled +} + +func checkMySQLEventRestoreWarnings(ctx context.Context, conn *sql.Conn, statement string) error { + event, active := mysqlEventStatement(mysqlEventTokens(statement, false)) + otherEvent, otherActive := mysqlEventStatement(mysqlEventTokens(statement, true)) + if !event && !otherEvent { + return nil + } + rows, err := conn.QueryContext(ctx, "SHOW WARNINGS") + if err != nil { + return fmt.Errorf("读取 MySQL 事件恢复警告失败:%w", err) + } + defer rows.Close() + for rows.Next() { + var level, message string + var code int + if err := rows.Scan(&level, &code, &message); err != nil { + return err + } + // 到期自动删除、拒绝修改或改变启停状态,不能算作成功恢复。 + if code == 1588 || code == 1589 || code == 1605 || (code == 1544 && (active || otherActive)) { + return fmt.Errorf("MySQL 事件恢复未保留原定义或状态(警告 %d):%s", code, message) + } + } + return rows.Err() +} + +func mysqlSkipEventName(tokens []string, start int) int { + if start >= len(tokens) { + return start + } + start++ + if start+1 < len(tokens) && tokens[start] == "." { + start += 2 + } + return start +} + +// 只用于调度预检,不执行或改写 SQL。版本化可执行注释按可能执行处理。 +func mysqlEventTokens(statement string, noBackslash bool) []string { + var tokens []string + for i := 0; i < len(statement); { + c := statement[i] + if c <= ' ' { + i++ + continue + } + if c == '#' || (c == '-' && i+2 < len(statement) && statement[i+1] == '-' && statement[i+2] <= ' ') { + for i < len(statement) && statement[i] != '\n' { + i++ + } + continue + } + if c == '/' && i+1 < len(statement) && statement[i+1] == '*' { + end := strings.Index(statement[i+2:], "*/") + if end < 0 { + return append(tokens, "?") + } + end += i + 2 + if i+2 < end && statement[i+2] == '!' { + start := i + 3 + for start < end && statement[start] >= '0' && statement[start] <= '9' { + start++ + } + tokens = append(tokens, mysqlEventTokens(statement[start:end], noBackslash)...) + } + i = end + 2 + continue + } + if c == '\'' || c == '"' || c == '`' { + i++ + for i < len(statement) { + if statement[i] == '\\' && c != '`' && !noBackslash { + i += 2 + continue + } + if statement[i] == c { + i++ + if i < len(statement) && statement[i] == c { + i++ + continue + } + break + } + i++ + } + tokens = append(tokens, "?") + continue + } + start := i + for i < len(statement) && ((statement[i] >= 'a' && statement[i] <= 'z') || (statement[i] >= 'A' && statement[i] <= 'Z') || (statement[i] >= '0' && statement[i] <= '9') || statement[i] == '_' || statement[i] >= 128) { + i++ + } + if i == start { + i++ + } + tokens = append(tokens, strings.ToUpper(statement[start:i])) + } + return tokens +} diff --git a/backend/internal/cli/mysql_event_restore_test.go b/backend/internal/cli/mysql_event_restore_test.go new file mode 100644 index 0000000..789769b --- /dev/null +++ b/backend/internal/cli/mysql_event_restore_test.go @@ -0,0 +1,41 @@ +package cli + +import "testing" + +func TestMySQLEventStatementSafety(t *testing.T) { + for _, item := range []struct { + name, statement string + active bool + }{ + {"default_enabled", "CREATE EVENT task ON SCHEDULE EVERY 1 DAY DO SELECT 1", true}, + {"explicit_enabled", "CREATE DEFINER=`owner`@`host` EVENT task ON SCHEDULE EVERY 1 DAY ENABLE DO SELECT 1", true}, + {"disabled", "CREATE EVENT task ON SCHEDULE EVERY 1 DAY DISABLE DO SELECT 'ENABLE'", false}, + {"replica_disabled", "CREATE EVENT task ON SCHEDULE EVERY 1 DAY DISABLE ON SLAVE DO SELECT 1", false}, + {"body_not_status", "CREATE EVENT task ON SCHEDULE EVERY 1 DAY DO SELECT 'DISABLE'", true}, + {"comment_not_status", "CREATE EVENT task ON SCHEDULE EVERY 1 DAY COMMENT 'DISABLE' DO SELECT 1", true}, + {"name_not_status", "CREATE EVENT disable ON SCHEDULE EVERY 1 DAY DO SELECT 1", true}, + {"schema_not_status", "CREATE EVENT disable.task ON SCHEDULE EVERY 1 DAY DO SELECT 1", true}, + {"quoted_name", "CREATE EVENT `DO``DISABLE` ON SCHEDULE EVERY 1 DAY DO SELECT 1", true}, + {"if_not_exists", "CREATE EVENT IF NOT EXISTS disable ON SCHEDULE EVERY 1 DAY DO SELECT 1", true}, + {"definer_keywords", "CREATE DEFINER=event@disable EVENT task ON SCHEDULE EVERY 1 DAY DO SELECT 1", true}, + {"current_user", "CREATE DEFINER=CURRENT_USER() EVENT task ON SCHEDULE EVERY 1 DAY DISABLE DO SELECT 1", false}, + {"versioned", "/*!50106 CREATE*/ /*!50117 DEFINER=`owner`@`host`*/ /*!50106 EVENT task ON SCHEDULE EVERY 1 DAY DO SELECT 1 */", true}, + {"versioned_disabled", "/*!50106 CREATE*/ /*!50117 DEFINER=`owner`@`host`*/ /*!50106 EVENT task ON SCHEDULE EVERY 1 DAY DISABLE DO SELECT 1 */", false}, + {"alter_enable", "ALTER EVENT task ENABLE", true}, + {"alter_disable", "ALTER EVENT task DISABLE", false}, + {"alter_rename", "ALTER EVENT task RENAME TO disable", true}, + {"alter_preserves_unknown_state", "ALTER EVENT task DO SELECT 'DISABLE'", true}, + {"drop", "DROP EVENT IF EXISTS task", false}, + {"ordinary_comment", "/* CREATE EVENT task ENABLE */ SELECT 1", false}, + {"line_comment", "-- CREATE EVENT task ENABLE\nCREATE EVENT task ON SCHEDULE EVERY 1 DAY DISABLE DO SELECT 1", false}, + {"routine", "CREATE DEFINER=`owner`@`host` PROCEDURE task() SELECT 'EVENT DISABLE'", false}, + } { + t.Run(item.name, func(t *testing.T) { + for _, mode := range []bool{false, true} { + if actual := mysqlStatementMayEnableEvent(mysqlEventTokens(item.statement, mode)); actual != item.active { + t.Fatalf("事件调度识别错误:得到 %v,期望 %v,语句 %s", actual, item.active, item.statement) + } + } + }) + } +} diff --git a/backend/internal/cli/mysql_event_safety_integration_test.go b/backend/internal/cli/mysql_event_safety_integration_test.go new file mode 100644 index 0000000..02bba2c --- /dev/null +++ b/backend/internal/cli/mysql_event_safety_integration_test.go @@ -0,0 +1,147 @@ +package cli + +import ( + "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 TestMySQLRealEventRestoreSafety(t *testing.T) { + server := mysqltest.New(t) + for _, scenario := range []string{"incoming_enabled", "incoming_versioned", "incoming_default", "target_enabled", "target_hidden", "disabled_allowed", "scheduler_off", "missing_process", "script_changes_scheduler"} { + t.Run(scenario, func(t *testing.T) { + cfg := server.Database(t) + parsed, err := mysql.ParseDSN(cfg.DSN) + if err != nil { + t.Fatal(err) + } + account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'" + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.DB.ExecContext(t.Context(), "CREATE TABLE restore_marker (id INT PRIMARY KEY) ENGINE=InnoDB"); err != nil { + t.Fatal(err) + } + event := "CREATE EVENT scheduled ON SCHEDULE AT '2036-01-01 00:00:00' ON COMPLETION PRESERVE ENABLE DO SELECT 'DISABLE'" + script := "INSERT INTO restore_marker VALUES (1);\n" + event + ";" + state, expected := "ON", "事件调度器仍为 ON" + switch scenario { + case "incoming_versioned": + script = "INSERT INTO restore_marker VALUES (1);\n/*!50106 " + event + " */;" + case "incoming_default": + script = "INSERT INTO restore_marker VALUES (1);\nCREATE EVENT disable ON SCHEDULE EVERY 1 DAY DO SELECT 1;" + case "target_enabled", "target_hidden": + if _, err := db.DB.ExecContext(t.Context(), event); err != nil { + t.Fatal(err) + } + script = "INSERT INTO restore_marker VALUES (1);" + if scenario == "target_hidden" { + if _, err := server.Admin().ExecContext(t.Context(), "REVOKE EVENT ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+account); err != nil { + t.Fatal(err) + } + expected = "EVENT" + } + case "disabled_allowed": + script = "INSERT INTO restore_marker VALUES (1);\nCREATE EVENT scheduled ON SCHEDULE EVERY 1 DAY DISABLE DO SELECT 1;" + expected = "" + case "scheduler_off": + state, expected = "OFF", "" + case "missing_process": + if _, err := server.Admin().ExecContext(t.Context(), "REVOKE PROCESS ON *.* FROM "+account); err != nil { + t.Fatal(err) + } + expected = "PROCESS" + case "script_changes_scheduler": + script = "INSERT INTO restore_marker VALUES (1);\nSET GLOBAL event_scheduler = ON;" + expected = "恢复文件不能修改" + } + if _, err := server.Admin().ExecContext(t.Context(), "SET GLOBAL event_scheduler = "+state); err != nil { + t.Fatal(err) + } + input := filepath.Join(server.Root(), scenario+"-restore.sql") + if err := os.WriteFile(input, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + err = restoreMySQL(t.Context(), cfg, input) + count := "0" + if expected == "" { + if err != nil { + t.Fatal(err) + } + count = "1" + } else if err == nil || !strings.Contains(err.Error(), expected) { + t.Fatalf("恢复前应拒绝且不执行首条语句,期望 %s,得到 %v", expected, err) + } + checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM restore_marker", count) + checkMySQLInteropValue(t, cfg, "SELECT @@GLOBAL.event_scheduler", state) + }) + } + if _, err := server.Admin().ExecContext(t.Context(), "SET GLOBAL event_scheduler = OFF"); err != nil { + t.Fatal(err) + } + // OFF 不代表已经执行的事件被终止;实库确认工作线程存在时必须拒绝恢复。 + t.Run("running_worker_after_scheduler_off", func(t *testing.T) { + cfg := server.Database(t) + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.DB.ExecContext(t.Context(), "CREATE EVENT waiting_event ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 SECOND ON COMPLETION PRESERVE ENABLE DO SELECT SLEEP(4)"); err != nil { + t.Fatal(err) + } + if _, err := server.Admin().ExecContext(t.Context(), "SET GLOBAL event_scheduler = ON"); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(8 * time.Second) + var workers int + for time.Now().Before(deadline) { + if err := server.Admin().QueryRowContext(t.Context(), "SELECT COUNT(*) FROM information_schema.PROCESSLIST WHERE COMMAND = 'Connect' AND STATE = 'User sleep'").Scan(&workers); err != nil { + t.Fatal(err) + } + if workers > 0 { + break + } + time.Sleep(25 * time.Millisecond) + } + if workers == 0 { + t.Fatal("没有观测到事件工作线程,不能把未覆盖的场景算作通过") + } + if _, err := server.Admin().ExecContext(t.Context(), "SET GLOBAL event_scheduler = OFF"); err != nil { + t.Fatal(err) + } + input := filepath.Join(server.Root(), "running-worker.sql") + if err := os.WriteFile(input, []byte("CREATE TABLE must_not_exist (id INT);"), 0o600); err != nil { + t.Fatal(err) + } + if err := restoreMySQL(t.Context(), cfg, input); err == nil || !strings.Contains(err.Error(), "工作线程") { + t.Fatalf("事件尚未退出时应拒绝:%v", err) + } + checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()", "0") + deadline = time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + if err := server.Admin().QueryRowContext(t.Context(), "SELECT COUNT(*) FROM information_schema.PROCESSLIST WHERE COMMAND = 'Connect'").Scan(&workers); err != nil { + t.Fatal(err) + } + if workers == 0 { + break + } + time.Sleep(25 * time.Millisecond) + } + if workers != 0 { + t.Fatal("事件线程未在预期时间自然退出") + } + if err := restoreMySQL(t.Context(), cfg, input); err != nil { + t.Fatal(err) + } + }) +} diff --git a/backend/internal/cli/mysql_events.go b/backend/internal/cli/mysql_events.go new file mode 100644 index 0000000..7e36a4d --- /dev/null +++ b/backend/internal/cli/mysql_events.go @@ -0,0 +1,113 @@ +package cli + +import ( + "context" + "database/sql" + "fmt" + "io" + "strings" + "unicode/utf8" +) + +func listMySQLEvents(ctx context.Context, tx *sql.Tx) ([]string, error) { + if err := requireMySQLEventVisibility(ctx, tx); err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, "SELECT EVENT_NAME FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE() ORDER BY EVENT_NAME") + if err != nil { + return nil, fmt.Errorf("读取 MySQL 事件列表失败:%w", err) + } + defer rows.Close() + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +func dumpMySQLEvents(ctx context.Context, tx *sql.Tx, output io.Writer, names []string) error { + if len(names) == 0 { + return nil + } + var databaseCollation string + if err := tx.QueryRowContext(ctx, "SELECT @@collation_database").Scan(&databaseCollation); err != nil { + return err + } + if _, err := fmt.Fprintln(output, `SET @scert_event_sql_mode = @@SESSION.sql_mode; +SET @scert_event_charset = @@SESSION.character_set_client; +SET @scert_event_collation = @@SESSION.collation_connection; +SET @scert_event_time_zone = @@SESSION.time_zone;`); err != nil { + return err + } + for _, name := range names { + var expired sql.NullBool + var status, completion string + query := `SELECT CASE + WHEN EXECUTE_AT IS NOT NULL THEN EXECUTE_AT <= CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', TIME_ZONE) + WHEN ENDS IS NOT NULL THEN ENDS <= CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', TIME_ZONE) + ELSE FALSE END, STATUS, ON_COMPLETION + FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE() AND EVENT_NAME = ?` + if err := tx.QueryRowContext(ctx, query, name).Scan(&expired, &status, &completion); err != nil { + return fmt.Errorf("读取 MySQL 事件到期状态失败:%w", err) + } + if !expired.Valid || (expired.Bool && (status == "ENABLED" || completion == "NOT PRESERVE")) { + return fmt.Errorf("MySQL 事件已到期或时区不可验证,重新创建将改变状态或删除对象,拒绝生成不完整备份:%s", name) + } + definition, err := readMySQLStoredDefinition(ctx, tx, "EVENT", name, "Create Event") + if err != nil { + return err + } + zone, exists := definition["time_zone"] + if !exists || zone == "" { + return fmt.Errorf("MySQL 事件时区字段不完整:%s", name) + } + if err := normalizeMySQLEventMetadata(ctx, tx, name, definition); err != nil { + return err + } + if _, err := fmt.Fprintf(output, "SET SESSION time_zone = %s;\n", mysqlStringLiteral(zone)); err != nil { + return err + } + drop := "DROP EVENT IF EXISTS " + quoteMySQLIdentifier(name) + if err := writeMySQLStoredDefinition(ctx, tx, output, definition, "Create Event", drop); err != nil { + return fmt.Errorf("写入 MySQL 事件失败(%s):%w", name, err) + } + } + _, err := fmt.Fprintf(output, "ALTER DATABASE COLLATE %s;\nSET SESSION sql_mode = @scert_event_sql_mode;\nSET SESSION character_set_client = @scert_event_charset;\nSET SESSION collation_connection = @scert_event_collation;\nSET SESSION time_zone = @scert_event_time_zone;\n\n", quoteMySQLIdentifier(databaseCollation)) + return err +} + +// 两个版本的事件声明头均来自 UTF-8 目录,正文仍使用原客户端编码。 +// 先与目录正文比对边界,再单独反解声明头;失败不写回部分转换结果。 +func normalizeMySQLEventMetadata(ctx context.Context, tx *sql.Tx, name string, definition map[string]string) error { + charset := definition["character_set_client"] + if charset == "utf8" || charset == "utf8mb3" || charset == "utf8mb4" { + return nil + } + var body string + if err := tx.QueryRowContext(ctx, "SELECT EVENT_DEFINITION FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE() AND EVENT_NAME = ?", name).Scan(&body); err != nil { + return fmt.Errorf("读取 MySQL 事件编码元数据失败:%w", err) + } + original := definition["Create Event"] + if body == "" || !strings.HasSuffix(original, body) { + return fmt.Errorf("MySQL 事件正文与目录元数据不一致,拒绝猜测编码:%s", name) + } + header := original[:len(original)-len(body)] + if !strings.HasSuffix(header, " DO ") { + return fmt.Errorf("MySQL 事件声明头不完整:%s", name) + } + identifier := quoteMySQLIdentifier(charset) + var encoded, roundTrip []byte + query := "SELECT CAST(CONVERT(? USING " + identifier + ") AS BINARY), CAST(CONVERT(CONVERT(? USING " + identifier + ") USING utf8mb4) AS BINARY)" + if err := tx.QueryRowContext(ctx, query, header, header).Scan(&encoded, &roundTrip); err != nil { + return fmt.Errorf("MySQL 事件目录元数据无法无损还原为 UTF-8(%s):%w", name, err) + } + if string(roundTrip) != header || !utf8.Valid(encoded) { + return fmt.Errorf("MySQL 事件目录元数据无法无损还原为 UTF-8:%s", name) + } + definition["Create Event"] = string(encoded) + body + return nil +} diff --git a/backend/internal/cli/mysql_events_integration_test.go b/backend/internal/cli/mysql_events_integration_test.go new file mode 100644 index 0000000..eebceac --- /dev/null +++ b/backend/internal/cli/mysql_events_integration_test.go @@ -0,0 +1,273 @@ +package cli + +import ( + "database/sql" + "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 TestMySQLRealNativeEvents(t *testing.T) { + server := mysqltest.New(t) + if _, err := server.Admin().ExecContext(t.Context(), "SET GLOBAL event_scheduler = OFF"); 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) + db, err := database.Open(t.Context(), source) + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn, err := db.DB.Conn(t.Context()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + for _, statement := range []string{ + "CREATE TABLE event_values (id INT PRIMARY KEY, value INT NOT NULL) ENGINE=InnoDB", + "INSERT INTO event_values VALUES (1, 10)", + "CREATE PROCEDURE event_action() UPDATE event_values SET value = value + 7 WHERE id = 1", + "SET SESSION time_zone = '+08:00'", + "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'", + "CREATE EVENT `中文``事件` ON SCHEDULE AT '2036-01-02 03:04:05' ON COMPLETION PRESERVE DISABLE COMMENT '说明;;不要改写 DISABLE' DO BEGIN CALL event_action(); UPDATE event_values SET value = value + 3 WHERE id = 1; END", + "SET SESSION time_zone = '-05:00'", + "CREATE EVENT repeating_event ON SCHEDULE EVERY 2 DAY STARTS '2036-02-03 04:05:06' ENDS '2037-03-04 05:06:07' ON COMPLETION PRESERVE ENABLE COMMENT '周期事件' DO CALL event_action()", + "CREATE EVENT replica_event ON SCHEDULE EVERY 3 HOUR STARTS '2036-04-05 06:07:08' ON COMPLETION NOT PRESERVE DISABLE ON SLAVE DO CALL event_action()", + "ALTER DATABASE COLLATE latin1_swedish_ci", + "SET NAMES latin1 COLLATE latin1_bin", + "CREATE EVENT `legacy_caf" + string([]byte{0xe9}) + "` ON SCHEDULE EVERY 1 DAY DISABLE COMMENT 'caf" + string([]byte{0xe9}) + ";;' DO SELECT 'caf" + string([]byte{0xe9}) + ";;'", + "SET NAMES utf8mb4 COLLATE utf8mb4_bin", + "ALTER DATABASE COLLATE utf8mb4_unicode_ci", + } { + if _, err := conn.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + definition := readMySQLEventDefinitions(t, db.DB) + t.Run("metadata_rejection", func(t *testing.T) { + tx, err := db.DB.BeginTx(t.Context(), nil) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + original, err := readMySQLStoredDefinition(t.Context(), tx, "EVENT", "legacy_café", "Create Event") + if err != nil { + t.Fatal(err) + } + body := "SELECT 'café;;'" + for _, item := range []struct{ name, statement, expected string }{ + {"wrong_body", "CREATE EVENT x DO SELECT 'different'", "正文与目录"}, + {"missing_header", "CREATE EVENT x " + body, "声明头不完整"}, + {"lossy_header", "CREATE EVENT `中文` DO " + body, "无法无损"}, + {"invalid_utf8_header", "CREATE EVENT `ÿ` DO " + body, "无法无损"}, + } { + t.Run(item.name, func(t *testing.T) { + definition := make(map[string]string, len(original)) + for key, value := range original { + definition[key] = value + } + definition["Create Event"] = item.statement + err := normalizeMySQLEventMetadata(t.Context(), tx, "legacy_café", definition) + if err == nil || !strings.Contains(err.Error(), item.expected) { + t.Fatalf("无效编码元数据没有明确拒绝:%v", err) + } + if definition["Create Event"] != item.statement { + t.Fatal("失败后留下部分转换定义") + } + }) + } + }) + output := filepath.Join(server.Root(), "native-events.sql") + if err := backupMySQLDatabase(t.Context(), source, output); err != nil { + t.Fatal(err) + } + for _, direction := range []string{"scert_to_scert", "scert_to_official"} { + 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() + verify, err := database.Open(t.Context(), target) + if err != nil { + t.Fatal(err) + } + defer verify.Close() + if _, err := verify.DB.ExecContext(t.Context(), "CREATE EVENT `中文``事件` ON SCHEDULE EVERY 1 DAY DISABLE DO SELECT 1"); err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 2; attempt++ { + if direction == "scert_to_official" { + server.Import(t, target, output) + } else if err := restoreMySQL(t.Context(), target, output); err != nil { + t.Fatal(err) + } + if actual := readMySQLEventDefinitions(t, verify.DB); actual != definition { + t.Fatalf("事件定义、计划、时区或状态不一致:\n源:%s\n恢复:%s", definition, actual) + } + checkMySQLInteropValue(t, target, "SELECT CAST(value AS CHAR) FROM event_values WHERE id = 1", "10") + } + // 只在数据、例程、事件全部恢复并核验后,在专用实例显式开启调度。 + if _, err := verify.DB.ExecContext(t.Context(), "ALTER EVENT `中文``事件` ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND ENABLE"); err != nil { + t.Fatal(err) + } + if _, err := server.Admin().ExecContext(t.Context(), "SET GLOBAL event_scheduler = ON"); err != nil { + t.Fatal(err) + } + defer func() { + if _, err := server.Admin().ExecContext(t.Context(), "SET GLOBAL event_scheduler = OFF"); err != nil { + t.Error(err) + } + }() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + var value int + if err := verify.DB.QueryRowContext(t.Context(), "SELECT value FROM event_values WHERE id = 1").Scan(&value); err != nil { + t.Fatal(err) + } + if value == 20 { + break + } + time.Sleep(25 * time.Millisecond) + } + checkMySQLInteropValue(t, target, "SELECT CAST(value AS CHAR) FROM event_values WHERE id = 1", "20") + checkMySQLInteropValue(t, source, "SELECT CAST(value AS CHAR) FROM event_values WHERE id = 1", "10") + }) + } +} + +func readMySQLEventDefinitions(t *testing.T, db *sql.DB) string { + t.Helper() + // 8.0 的 JSON_ARRAY 可能把相同周期文本显示成不透明 Base64。 + // 同时比较显式文本、原始字节、字符集及排序规则,不忽略实际周期值差异。 + rows, err := db.QueryContext(t.Context(), `SELECT JSON_ARRAY(EVENT_NAME, DEFINER, TIME_ZONE, + EVENT_BODY, EVENT_DEFINITION, EVENT_TYPE, EXECUTE_AT, CONVERT(INTERVAL_VALUE USING utf8mb4), + HEX(INTERVAL_VALUE), CHARSET(INTERVAL_VALUE), COLLATION(INTERVAL_VALUE), INTERVAL_FIELD, + STARTS, ENDS, 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`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var definitions []string + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + t.Fatal(err) + } + definitions = append(definitions, value) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return strings.Join(definitions, "\n") +} + +func TestMySQLRealEventVisibility(t *testing.T) { + server := mysqltest.New(t) + scenarios := []string{"visible", "hidden", "empty_without_permission", "global", "unrelated_schema", "escaped_schema", "shadowed_schema", "shadowed_exact"} + if server.IsMySQL8() { + scenarios = append(scenarios, "active_role", "nested_role", "inactive_role", "partial_revoke", "partial_revoke_with_schema") + } + for _, scenario := range scenarios { + t.Run(scenario, func(t *testing.T) { + cfg := server.Database(t) + parsed, err := mysql.ParseDSN(cfg.DSN) + if err != nil { + t.Fatal(err) + } + account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'" + if _, err := server.Admin().ExecContext(t.Context(), "GRANT EVENT ON "+quoteMySQLIdentifier(parsed.DBName)+".* TO "+account); err != nil { + t.Fatal(err) + } + if scenario != "empty_without_permission" { + if _, err := server.Admin().ExecContext(t.Context(), "CREATE EVENT "+quoteMySQLIdentifier(parsed.DBName)+".hidden_event ON SCHEDULE EVERY 1 DAY DISABLE DO SELECT 1"); err != nil { + t.Fatal(err) + } + } + if scenario != "visible" { + if _, err := server.Admin().ExecContext(t.Context(), "REVOKE EVENT ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+account); err != nil { + t.Fatal(err) + } + } + schema := quoteMySQLIdentifier(parsed.DBName) + var grants []string + switch scenario { + case "global": + grants = append(grants, "GRANT EVENT ON *.* TO "+account) + case "unrelated_schema": + grants = append(grants, "GRANT EVENT ON `unrelated`.* TO "+account) + case "escaped_schema": + escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_") + grants = append(grants, "GRANT SELECT, EVENT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account) + case "shadowed_schema": + grants = append(grants, "GRANT EVENT ON `scert%`.* TO "+account) + case "shadowed_exact": + escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_") + grants = append(grants, "GRANT EVENT ON "+schema+".* TO "+account, "GRANT SELECT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account) + case "active_role", "nested_role", "inactive_role": + role := quoteMySQLIdentifier("event_"+scenario) + "@'%'" + grants = append(grants, "CREATE ROLE "+role, "GRANT EVENT ON "+schema+".* TO "+role, "GRANT "+role+" TO "+account) + if scenario == "active_role" { + grants = append(grants, "SET DEFAULT ROLE "+role+" TO "+account) + } + if scenario == "nested_role" { + parent := "'event_parent'@'%'" + grants = append(grants, "REVOKE "+role+" FROM "+account, "CREATE ROLE "+parent, "GRANT "+role+" TO "+parent, "GRANT "+parent+" TO "+account, "SET DEFAULT ROLE "+parent+" TO "+account) + } + case "partial_revoke", "partial_revoke_with_schema": + grants = append(grants, "SET GLOBAL partial_revokes = ON", "GRANT EVENT ON *.* TO "+account, "REVOKE EVENT ON "+schema+".* FROM "+account) + if scenario == "partial_revoke_with_schema" { + grants = append(grants, "GRANT EVENT ON "+schema+".* TO "+account) + } + } + for _, grant := range grants { + if _, err := server.Admin().ExecContext(t.Context(), grant); err != nil { + t.Fatal(err) + } + } + allowed := scenario == "visible" || scenario == "global" || scenario == "escaped_schema" || scenario == "active_role" || scenario == "nested_role" || scenario == "partial_revoke_with_schema" + count := "0" + if allowed { + count = "1" + } + checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE()", count) + output := filepath.Join(server.Root(), scenario+"-events.sql") + err = backupMySQLDatabase(t.Context(), cfg, output) + if allowed { + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(output) + if err != nil || !strings.Contains(string(data), "hidden_event") { + t.Fatal("可见事件未导出") + } + return + } + if err == nil || !strings.Contains(err.Error(), "EVENT") { + t.Fatalf("缺少事件可见权限必须拒绝,不能生成不完整备份:%v", err) + } + for _, path := range []string{output, output + ".tmp"} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("权限失败后留下了备份文件:%s", path) + } + } + }) + } +} diff --git a/backend/internal/cli/mysql_privileges.go b/backend/internal/cli/mysql_privileges.go index f7b1739..74629db 100644 --- a/backend/internal/cli/mysql_privileges.go +++ b/backend/internal/cli/mysql_privileges.go @@ -85,6 +85,30 @@ func readMySQLEffectiveGrants(ctx context.Context, tx *sql.Tx, version string) ( } func requireMySQLTableTriggerGrants(ctx context.Context, tx *sql.Tx, name string, tables, grants []string, normalize func(string) string) error { + return requireMySQLObjectGrants(ctx, tx, name, tables, grants, normalize, "TRIGGER", false) +} + +func requireMySQLEventVisibility(ctx context.Context, tx *sql.Tx) error { + var name, version string + var lowerCaseNames int + if err := tx.QueryRowContext(ctx, "SELECT DATABASE(), VERSION(), @@lower_case_table_names").Scan(&name, &version, &lowerCaseNames); err != nil { + return err + } + grants, err := readMySQLEffectiveGrants(ctx, tx, version) + if err != nil { + return err + } + normalize := func(value string) string { + if lowerCaseNames != 0 { + return strings.ToLower(value) + } + return value + } + // EVENT 只有全局或库级授权,不能用某一张表的权限证明完整事件列表可见。 + return requireMySQLObjectGrants(ctx, tx, normalize(name), []string{name}, grants, normalize, "EVENT", true) +} + +func requireMySQLObjectGrants(ctx context.Context, tx *sql.Tx, name string, objects, grants []string, normalize func(string) string, privilege string, schemaOnly bool) error { // 5.7 返回空列表;8.0 部分撤销模式下,数据库授权中的百分号和下划线为字面量。 partialRevokes := false rows, err := tx.QueryContext(ctx, "SHOW GLOBAL VARIABLES LIKE 'partial_revokes'") @@ -115,9 +139,9 @@ func requireMySQLTableTriggerGrants(ctx context.Context, tx *sql.Tx, name string if len(match) == 0 { continue } - hasTrigger := mysqlHasTriggerPrivilege(match[2]) + hasPrivilege := mysqlHasPrivilege(match[2], privilege) if match[3] == "*" && match[4] == "*" { - global = global || (match[1] == "GRANT" && hasTrigger) + global = global || (match[1] == "GRANT" && hasPrivilege) continue } databaseName := normalize(unquoteMySQLGrantIdentifier(match[3])) @@ -131,16 +155,16 @@ func requireMySQLTableTriggerGrants(ctx context.Context, tx *sql.Tx, name string continue } if match[1] == "REVOKE" { - revoked = revoked || hasTrigger + revoked = revoked || hasPrivilege } else if match[4] == "*" { if partialRevokes || !mysqlGrantHasWildcard(databaseName) { exactSeen = true - exactAllowed = exactAllowed || hasTrigger + exactAllowed = exactAllowed || hasPrivilege } else { wildcardSeen = true - wildcardAllowed = wildcardAllowed && hasTrigger + wildcardAllowed = wildcardAllowed && hasPrivilege } - } else if hasTrigger { + } else if hasPrivilege && !schemaOnly { covered[normalize(unquoteMySQLGrantIdentifier(match[4]))] = true } } @@ -150,9 +174,9 @@ func requireMySQLTableTriggerGrants(ctx context.Context, tx *sql.Tx, name string if !exactSeen { schema = wildcardSeen && wildcardAllowed } - for _, table := range tables { - if !((global && !revoked) || schema || covered[normalize(table)]) { - return fmt.Errorf("MySQL 备份无法确认表 %s 的 TRIGGER 可见权限,不能把隐藏的触发器当成不存在", table) + for _, object := range objects { + if !((global && !revoked) || schema || covered[normalize(object)]) { + return fmt.Errorf("MySQL 无法确认对象 %s 的 %s 可见权限,不能把隐藏的对象当成不存在", object, privilege) } } return nil @@ -173,8 +197,12 @@ func mysqlGrantHasWildcard(value string) bool { } func mysqlHasTriggerPrivilege(privileges string) bool { + return mysqlHasPrivilege(privileges, "TRIGGER") +} + +func mysqlHasPrivilege(privileges, required string) bool { for _, privilege := range strings.Split(privileges, ",") { - if value := strings.TrimSpace(privilege); value == "TRIGGER" || value == "ALL PRIVILEGES" { + if value := strings.TrimSpace(privilege); value == required || value == "ALL PRIVILEGES" { return true } } diff --git a/backend/internal/testsupport/mysqltest/server.go b/backend/internal/testsupport/mysqltest/server.go index 40cc57a..0471785 100644 --- a/backend/internal/testsupport/mysqltest/server.go +++ b/backend/internal/testsupport/mysqltest/server.go @@ -212,7 +212,8 @@ func (f *Server) Database(t *testing.T) config.DatabaseConfig { for _, statement := range []string{ "CREATE DATABASE " + name + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", "CREATE USER '" + user + "'@'127.0.0.1' IDENTIFIED BY '" + f.password + "' REQUIRE SSL", - "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, TRIGGER ON " + name + ".* TO '" + user + "'@'127.0.0.1'", + "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, TRIGGER, EVENT ON " + name + ".* TO '" + user + "'@'127.0.0.1'", + "GRANT PROCESS ON *.* TO '" + user + "'@'127.0.0.1'", } { if _, err := f.admin.ExecContext(t.Context(), statement); err != nil { t.Fatal("创建隔离数据库及库级账户失败")