From c4051c91664bf2ed1d6659390b5d703685741b99 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 27 Sep 2026 22:16:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98MySQL=E4=BE=8B=E7=A8=8B?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=E6=95=B4=E6=94=B9=E6=A3=80=E6=9F=A5=E7=82=B9?= =?UTF-8?q?=EF=BC=88=E9=AA=8C=E8=AF=81=E6=9C=AA=E5=AE=8C=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留存储过程与函数导出、权限检查、编码处理、共享存储对象逻辑及正式测试,同步两份任务进度文档。 已知阻塞:已有MySQL 5.7实库记录中,两种恢复入口均存在Latin-1例程注释乱码;已有8.0编码与权限专项通过,但不能代替最终双版本全量复测。本次仅重新运行例程单元测试及相关包静态检查,均通过。 这是未完成整改的保存检查点,不是发布版本,不表示P0-05完成或生产验收通过。未纳入运行数据、构建产物、原始实库日志或凭据。 --- backend/internal/cli/mysql_backup.go | 10 +- backend/internal/cli/mysql_privileges.go | 30 +- .../internal/cli/mysql_routine_encoding.go | 86 ++++++ backend/internal/cli/mysql_routines.go | 131 +++++++++ .../cli/mysql_routines_integration_test.go | 256 ++++++++++++++++++ backend/internal/cli/mysql_routines_test.go | 50 ++++ .../internal/cli/mysql_stored_definition.go | 94 +++++++ backend/internal/cli/mysql_triggers.go | 61 +---- .../internal/testsupport/mysqltest/server.go | 8 + doc/Scert整改任务执行清单.md | 10 + doc/基础整改任务规格.md | 7 + 11 files changed, 674 insertions(+), 69 deletions(-) create mode 100644 backend/internal/cli/mysql_routine_encoding.go create mode 100644 backend/internal/cli/mysql_routines.go create mode 100644 backend/internal/cli/mysql_routines_integration_test.go create mode 100644 backend/internal/cli/mysql_routines_test.go create mode 100644 backend/internal/cli/mysql_stored_definition.go diff --git a/backend/internal/cli/mysql_backup.go b/backend/internal/cli/mysql_backup.go index b445957..c452bfa 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 { @@ -88,12 +88,20 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output if err := requireMySQLTriggerVisibility(ctx, tx, tableNames); err != nil { return err } + routines, err := listMySQLRoutines(ctx, tx) + if err != nil { + return err + } for _, tableName := range tableNames { if err := dumpMySQLTable(ctx, tx, file, tableName); err != nil { return err } } + // 例程先于依赖它的触发器创建;例程定义不执行其中的业务语句。 + if err := dumpMySQLRoutines(ctx, tx, file, routines); err != nil { + return err + } // 必须先恢复所有基础数据,再创建触发器,避免导入时重放业务副作用。 if err := dumpMySQLTriggers(ctx, tx, file); err != nil { return err diff --git a/backend/internal/cli/mysql_privileges.go b/backend/internal/cli/mysql_privileges.go index 0baed27..f7b1739 100644 --- a/backend/internal/cli/mysql_privileges.go +++ b/backend/internal/cli/mysql_privileges.go @@ -27,27 +27,35 @@ func requireMySQLTriggerVisibility(ctx context.Context, tx *sql.Tx, tables []str return value } name = normalize(name) + grants, err := readMySQLEffectiveGrants(ctx, tx, version) + if err != nil { + return err + } + return requireMySQLTableTriggerGrants(ctx, tx, name, tables, grants, normalize) +} + +func readMySQLEffectiveGrants(ctx context.Context, tx *sql.Tx, version string) ([]string, error) { query := "SHOW GRANTS FOR CURRENT_USER" if strings.HasPrefix(version, "8.0.") { rows, err := tx.QueryContext(ctx, "SELECT ROLE_NAME, ROLE_HOST FROM information_schema.ENABLED_ROLES ORDER BY ROLE_NAME, ROLE_HOST") if err != nil { - return fmt.Errorf("读取 MySQL 当前角色失败:%w", err) + return nil, fmt.Errorf("读取 MySQL 当前角色失败:%w", err) } var roles []string for rows.Next() { var role, host string if err := rows.Scan(&role, &host); err != nil { _ = rows.Close() - return err + return nil, err } roles = append(roles, quoteMySQLIdentifier(role)+"@"+quoteMySQLIdentifier(host)) } if err := rows.Err(); err != nil { _ = rows.Close() - return err + return nil, err } if err := rows.Close(); err != nil { - return err + return nil, err } if len(roles) > 0 { query += " USING " + strings.Join(roles, ", ") @@ -55,27 +63,31 @@ func requireMySQLTriggerVisibility(ctx context.Context, tx *sql.Tx, tables []str } rows, err := tx.QueryContext(ctx, query) if err != nil { - return fmt.Errorf("读取 MySQL 有效权限失败:%w", err) + return nil, fmt.Errorf("读取 MySQL 有效权限失败:%w", err) } var grants []string for rows.Next() { var grant string if err := rows.Scan(&grant); err != nil { _ = rows.Close() - return err + return nil, err } grants = append(grants, grant) } if err := rows.Err(); err != nil { _ = rows.Close() - return err + return nil, err } if err := rows.Close(); err != nil { - return err + return nil, err } + return grants, nil +} + +func requireMySQLTableTriggerGrants(ctx context.Context, tx *sql.Tx, name string, tables, grants []string, normalize func(string) string) error { // 5.7 返回空列表;8.0 部分撤销模式下,数据库授权中的百分号和下划线为字面量。 partialRevokes := false - rows, err = tx.QueryContext(ctx, "SHOW GLOBAL VARIABLES LIKE 'partial_revokes'") + rows, err := tx.QueryContext(ctx, "SHOW GLOBAL VARIABLES LIKE 'partial_revokes'") if err != nil { return err } diff --git a/backend/internal/cli/mysql_routine_encoding.go b/backend/internal/cli/mysql_routine_encoding.go new file mode 100644 index 0000000..37b635d --- /dev/null +++ b/backend/internal/cli/mysql_routine_encoding.go @@ -0,0 +1,86 @@ +package cli + +import ( + "context" + "database/sql" + "fmt" + "strings" + "unicode/utf8" +) + +// MySQL 8.0 将 UTF-8 目录中的声明头和注释与原客户端编码的正文拼接, +// 再按客户端字符集返回 SHOW CREATE;非 UTF-8 例程必须分段还原,不能整体反解正文。 +func normalizeMySQLRoutineMetadata(ctx context.Context, tx *sql.Tx, routine mysqlRoutine, definition map[string]string, column string) error { + charset := definition["character_set_client"] + if charset == "utf8" || charset == "utf8mb3" || charset == "utf8mb4" { + return nil + } + original := definition[column] + end, err := mysqlRoutineHeaderEnd(original) + if err != nil { + return err + } + decode := func(value string) (string, error) { + var encoded []byte + query := "SELECT CAST(CONVERT(? USING " + quoteMySQLIdentifier(charset) + ") AS BINARY)" + if err := tx.QueryRowContext(ctx, query, value).Scan(&encoded); err != nil { + return "", err + } + if !utf8.Valid(encoded) { + return "", fmt.Errorf("MySQL 例程目录元数据无法无损还原为 UTF-8:%s", routine.name) + } + return string(encoded), nil + } + header, err := decode(original[:end]) + if err != nil { + return err + } + var comment string + var body sql.NullString + if err := tx.QueryRowContext(ctx, `SELECT ROUTINE_COMMENT, ROUTINE_DEFINITION FROM information_schema.ROUTINES + WHERE ROUTINE_SCHEMA = DATABASE() AND ROUTINE_NAME = ? AND ROUTINE_TYPE = ?`, routine.name, routine.kind).Scan(&comment, &body); err != nil { + return fmt.Errorf("读取 MySQL 例程编码元数据失败:%w", err) + } + rest := original[end:] + if comment != "" { + if !body.Valid || body.String == "" || !strings.HasSuffix(rest, body.String) { + return fmt.Errorf("MySQL 例程正文与目录元数据不一致,拒绝猜测编码:%s", routine.name) + } + metadata := rest[:len(rest)-len(body.String)] + // 注释位于声明选项的末尾;先隔离正文,避免误改业务 SQL 中的 COMMENT 文本。 + start := strings.LastIndex(metadata, "\n COMMENT ") + if start < 0 { + return fmt.Errorf("MySQL 例程注释声明不完整:%s", routine.name) + } + decoded, err := decode(metadata[start:]) + if err != nil { + return err + } + rest = metadata[:start] + decoded + body.String + } + definition[column] = header + rest + return nil +} + +func mysqlRoutineHeaderEnd(statement string) (int, error) { + var quote byte + for i := 0; i < len(statement); i++ { + current := statement[i] + if quote != 0 { + if current == quote { + if i+1 < len(statement) && statement[i+1] == quote { + i++ + } else { + quote = 0 + } + } + continue + } + if current == '`' || current == '"' || current == '\'' { + quote = current + } else if current == '(' { + return i, nil + } + } + return 0, fmt.Errorf("MySQL 例程声明缺少完整参数列表") +} diff --git a/backend/internal/cli/mysql_routines.go b/backend/internal/cli/mysql_routines.go new file mode 100644 index 0000000..740ce5f --- /dev/null +++ b/backend/internal/cli/mysql_routines.go @@ -0,0 +1,131 @@ +package cli + +import ( + "context" + "database/sql" + "fmt" + "io" + "strings" +) + +type mysqlRoutine struct { + name string + kind string + utf8MetadataHeader bool +} + +func listMySQLRoutines(ctx context.Context, tx *sql.Tx) ([]mysqlRoutine, 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 nil, fmt.Errorf("读取 MySQL 例程权限环境失败:%w", err) + } + query := "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() ORDER BY ROUTINE_TYPE, ROUTINE_NAME" + switch { + case strings.HasPrefix(version, "5.7."): + // 直接证明系统目录的完整读取权限,不能依赖会静默隐藏他人例程的元数据列表。 + rows, err := tx.QueryContext(ctx, "SELECT * FROM mysql.proc LIMIT 0") + if err != nil { + return nil, fmt.Errorf("MySQL 例程备份需要 mysql.proc 的完整 SELECT 权限:%w", err) + } + if err := rows.Close(); err != nil { + return nil, err + } + query = "SELECT name, type FROM mysql.proc WHERE db = DATABASE() ORDER BY type, name" + case strings.HasPrefix(version, "8.0."): + grants, err := readMySQLEffectiveGrants(ctx, tx, version) + if err != nil { + return nil, err + } + if !mysqlRoutineVisibilityGranted(grants, name, lowerCaseNames != 0) { + return nil, fmt.Errorf("MySQL 例程备份需要有效 SHOW_ROUTINE 或未被撤销的全局 SELECT 权限,不能把隐藏的例程当成不存在") + } + default: + return nil, fmt.Errorf("当前 MySQL 版本的完整例程可见权限尚未验证:%s", version) + } + rows, err := tx.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("读取 MySQL 例程列表失败:%w", err) + } + defer rows.Close() + var routines []mysqlRoutine + for rows.Next() { + var routine mysqlRoutine + if err := rows.Scan(&routine.name, &routine.kind); err != nil { + return nil, err + } + if routine.kind != "PROCEDURE" && routine.kind != "FUNCTION" { + return nil, fmt.Errorf("MySQL 例程类型不受支持:%s", routine.kind) + } + routine.utf8MetadataHeader = strings.HasPrefix(version, "8.0.") + routines = append(routines, routine) + } + return routines, rows.Err() +} + +// SHOW_ROUTINE 是独立动态权限;库级 EXECUTE 或 CREATE ROUTINE 不能证明所有定义都可见。 +func mysqlRoutineVisibilityGranted(grants []string, name string, foldCase bool) bool { + showRoutine, globalSelect, revoked := false, false, false + for _, grant := range grants { + match := mysqlPrivilegeGrant.FindStringSubmatch(grant) + if len(match) == 0 { + continue + } + selectPrivilege, showPrivilege := false, false + for _, privilege := range strings.Split(match[2], ",") { + switch strings.TrimSpace(privilege) { + case "SELECT", "ALL PRIVILEGES": + selectPrivilege = true + case "SHOW_ROUTINE": + showPrivilege = true + } + } + if match[1] == "GRANT" && match[3] == "*" && match[4] == "*" { + showRoutine = showRoutine || showPrivilege + globalSelect = globalSelect || selectPrivilege + } + schema := unquoteMySQLGrantIdentifier(match[3]) + sameSchema := schema == name || (foldCase && strings.EqualFold(schema, name)) + if match[1] == "REVOKE" && sameSchema && match[4] == "*" { + revoked = revoked || selectPrivilege + } + } + return showRoutine || (globalSelect && !revoked) +} + +func dumpMySQLRoutines(ctx context.Context, tx *sql.Tx, output io.Writer, routines []mysqlRoutine) error { + if len(routines) == 0 { + return nil + } + var databaseCollation string + if err := tx.QueryRowContext(ctx, "SELECT @@collation_database").Scan(&databaseCollation); err != nil { + return fmt.Errorf("读取 MySQL 数据库排序规则失败:%w", err) + } + if _, err := fmt.Fprintln(output, `SET @scert_routine_sql_mode = @@SESSION.sql_mode; +SET @scert_routine_charset = @@SESSION.character_set_client; +SET @scert_routine_collation = @@SESSION.collation_connection;`); err != nil { + return err + } + for _, routine := range routines { + column := "Create Procedure" + if routine.kind == "FUNCTION" { + column = "Create Function" + } + definition, err := readMySQLStoredDefinition(ctx, tx, routine.kind, routine.name, column) + if err != nil { + return err + } + if routine.utf8MetadataHeader { + if err := normalizeMySQLRoutineMetadata(ctx, tx, routine, definition, column); err != nil { + return err + } + } + // 同名例程必须替换;定义者和权限语义来自原始 SHOW CREATE,不擅自改写。 + drop := "DROP " + routine.kind + " IF EXISTS " + quoteMySQLIdentifier(routine.name) + if err := writeMySQLStoredDefinition(ctx, tx, output, definition, column, drop); err != nil { + return fmt.Errorf("写入 MySQL 例程失败(%s):%w", routine.name, err) + } + } + _, err := fmt.Fprintf(output, "ALTER DATABASE COLLATE %s;\nSET SESSION sql_mode = @scert_routine_sql_mode;\nSET SESSION character_set_client = @scert_routine_charset;\nSET SESSION collation_connection = @scert_routine_collation;\n\n", quoteMySQLIdentifier(databaseCollation)) + return err +} diff --git a/backend/internal/cli/mysql_routines_integration_test.go b/backend/internal/cli/mysql_routines_integration_test.go new file mode 100644 index 0000000..8899177 --- /dev/null +++ b/backend/internal/cli/mysql_routines_integration_test.go @@ -0,0 +1,256 @@ +package cli + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-sql-driver/mysql" + + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" +) + +func TestMySQLRealNativeRoutines(t *testing.T) { + server := mysqltest.New(t) + source := server.Database(t) + parsed, err := mysql.ParseDSN(source.DSN) + if err != nil { + t.Fatal(err) + } + grantMySQLProgramAccess(t, server, parsed.User, parsed.DBName) + // 只调整独立实例专用账户,不能修改共享服务的函数信任开关。 + account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'" + grant := "GRANT SELECT ON mysql.proc TO " + account + if server.IsMySQL8() { + grant = "GRANT SHOW_ROUTINE, SUPER ON *.* TO " + account + } + if _, err := server.Admin().ExecContext(t.Context(), grant); 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() + for _, statement := range []string{ + "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'", + "SET NAMES utf8mb4 COLLATE utf8mb4_bin", + "CREATE TABLE interop (id INT PRIMARY KEY, value INT NOT NULL, label VARCHAR(100)) ENGINE=InnoDB", + "INSERT INTO interop VALUES (1, 10, '原始值')", + "CREATE FUNCTION format_value(input_value DECIMAL(20,4)) RETURNS VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DETERMINISTIC NO SQL SQL SECURITY INVOKER COMMENT '函数说明' RETURN CONCAT('函数;', input_value)", + "CREATE PROCEDURE `change``value`(IN target_id INT, INOUT step_value INT, OUT result_label VARCHAR(100)) SQL SECURITY INVOKER COMMENT '过程说明' BEGIN UPDATE interop SET value = value + step_value WHERE id = target_id; SET step_value = step_value + 1; SET result_label = format_value(step_value); END", + "CREATE TRIGGER routine_trigger BEFORE INSERT ON interop FOR EACH ROW SET NEW.label = format_value(NEW.value)", + "ALTER DATABASE COLLATE latin1_swedish_ci", + "SET NAMES latin1 COLLATE latin1_bin", + "CREATE FUNCTION `legacy_caf" + string([]byte{0xe9}) + "`() RETURNS VARCHAR(100) DETERMINISTIC NO SQL COMMENT 'caf" + string([]byte{0xe9}) + ";;' RETURN 'caf" + string([]byte{0xe9}) + ";;'", + "SET NAMES utf8mb4 COLLATE utf8mb4_bin", + "ALTER DATABASE COLLATE utf8mb4_unicode_ci", + } { + if _, err := connection.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + definition := readMySQLRoutineDefinitions(t, db.DB) + output := filepath.Join(server.Root(), "native-routines.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 FUNCTION format_value(input_value INT) RETURNS INT DETERMINISTIC NO SQL RETURN -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 restored := readMySQLRoutineDefinitions(t, verify.DB); restored != definition { + t.Fatalf("例程定义、参数、权限上下文或字符集不一致:\n源:%s\n恢复:%s", definition, restored) + } + checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "10|原始值") + checkMySQLInteropValue(t, target, "SELECT format_value(7)", "函数;7.0000") + checkMySQLInteropValue(t, target, "SELECT `legacy_café`()", "café;;") + checkMySQLInteropValue(t, target, "SELECT @@collation_database", "utf8mb4_unicode_ci") + conn, err := verify.DB.Conn(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, statement := range []string{"SET @step = 5", "CALL `change``value`(1, @step, @label)", "INSERT INTO interop VALUES (2, 4, '新行')"} { + if _, err := conn.ExecContext(t.Context(), statement); err != nil { + _ = conn.Close() + t.Fatal(err) + } + } + var actual string + err = conn.QueryRowContext(t.Context(), "SELECT CONCAT(@step, '|', @label)").Scan(&actual) + _ = conn.Close() + if err != nil || actual != "6|函数;6.0000" { + t.Fatalf("过程输入输出参数错误:%q,%v", actual, err) + } + checkMySQLInteropValue(t, target, "SELECT CAST(value AS CHAR) FROM interop WHERE id = 1", "15") + checkMySQLInteropValue(t, target, "SELECT label FROM interop WHERE id = 2", "函数;4.0000") + } + }) + } +} + +func TestMySQLRealRoutineVisibility(t *testing.T) { + server := mysqltest.New(t) + scenarios := []string{"visible", "hidden", "empty_schema_without_permission", "global_select", "execute_only"} + if server.IsMySQL8() { + scenarios = append(scenarios, "active_role", "nested_role", "inactive_role", "partial_revoke", "partial_revoke_with_show") + } else { + scenarios = append(scenarios, "column_only") + } + 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'" + permission := "SELECT ON mysql.proc" + if server.IsMySQL8() { + permission = "SHOW_ROUTINE ON *.*" + } + if _, err := server.Admin().ExecContext(t.Context(), "GRANT "+permission+" TO "+account); err != nil { + t.Fatal(err) + } + if scenario != "empty_schema_without_permission" { + statement := "CREATE PROCEDURE " + quoteMySQLIdentifier(parsed.DBName) + ".hidden_routine() SELECT 123" + if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + allowed := scenario == "visible" || scenario == "global_select" || scenario == "active_role" || scenario == "nested_role" || scenario == "partial_revoke_with_show" + if scenario != "visible" && scenario != "partial_revoke_with_show" { + if _, err := server.Admin().ExecContext(t.Context(), "REVOKE "+permission+" FROM "+account); err != nil { + t.Fatal(err) + } + } + var statements []string + switch scenario { + case "global_select": + statements = append(statements, "GRANT SELECT ON *.* TO "+account) + case "execute_only": + statements = append(statements, "GRANT EXECUTE ON "+quoteMySQLIdentifier(parsed.DBName)+".* TO "+account) + case "column_only": + statements = append(statements, "GRANT SELECT (db, name, type) ON mysql.proc TO "+account) + case "active_role", "nested_role", "inactive_role": + role := quoteMySQLIdentifier("routine_"+scenario) + "@'%'" + statements = append(statements, "CREATE ROLE "+role, "GRANT SHOW_ROUTINE ON *.* TO "+role, "GRANT "+role+" TO "+account) + if scenario == "active_role" { + statements = append(statements, "SET DEFAULT ROLE "+role+" TO "+account) + } + if scenario == "nested_role" { + parent := "'routine_parent'@'%'" + statements = append(statements, "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_show": + // 先移除夹具的库级授权,否则一次 REVOKE 只移除该授权而不撤销全局权限。 + statements = append(statements, "REVOKE SELECT ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+account, + "SET GLOBAL partial_revokes = ON", "GRANT SELECT ON *.* TO "+account, "REVOKE SELECT ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+account) + } + for _, statement := range statements { + if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + if scenario == "hidden" || scenario == "empty_schema_without_permission" || scenario == "inactive_role" { + checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "0") + } + if allowed || scenario == "execute_only" { + checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "1") + } + if scenario == "execute_only" { + checkMySQLInteropValue(t, cfg, "SELECT CAST(ROUTINE_DEFINITION IS NULL AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "1") + } + output := filepath.Join(server.Root(), "routines-"+scenario+".sql") + if err := os.WriteFile(output, []byte("existing backup"), 0o600); err != nil { + t.Fatal(err) + } + 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_routine") { + t.Fatal("可见例程未完整导出") + } + } else { + if err == nil || !strings.Contains(err.Error(), "例程") { + t.Fatalf("缺少完整例程可见权限必须拒绝:%v", err) + } + data, readErr := os.ReadFile(output) + if readErr != nil || string(data) != "existing backup" { + t.Fatal("权限不足破坏了原有备份") + } + if _, err := os.Stat(output + ".tmp"); !os.IsNotExist(err) { + t.Fatal("权限不足遗留临时文件") + } + } + }) + } +} + +func readMySQLRoutineDefinitions(t *testing.T, db *sql.DB) string { + t.Helper() + queries := []string{ + `SELECT JSON_ARRAY(ROUTINE_NAME, ROUTINE_TYPE, DTD_IDENTIFIER, ROUTINE_BODY, ROUTINE_DEFINITION, + IS_DETERMINISTIC, SQL_DATA_ACCESS, SECURITY_TYPE, SQL_MODE, ROUTINE_COMMENT, DEFINER, + CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION) + FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() ORDER BY ROUTINE_TYPE, ROUTINE_NAME`, + `SELECT JSON_ARRAY(SPECIFIC_NAME, ORDINAL_POSITION, PARAMETER_MODE, PARAMETER_NAME, + DTD_IDENTIFIER, CHARACTER_SET_NAME, COLLATION_NAME, ROUTINE_TYPE) + FROM information_schema.PARAMETERS WHERE SPECIFIC_SCHEMA = DATABASE() ORDER BY SPECIFIC_NAME, ORDINAL_POSITION`, + } + var definitions []string + for _, query := range queries { + rows, err := db.QueryContext(t.Context(), query) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var definition string + if err := rows.Scan(&definition); err != nil { + _ = rows.Close() + t.Fatal(err) + } + definitions = append(definitions, definition) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + t.Fatal(err) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + } + return strings.Join(definitions, "\n") +} diff --git a/backend/internal/cli/mysql_routines_test.go b/backend/internal/cli/mysql_routines_test.go new file mode 100644 index 0000000..72d4e81 --- /dev/null +++ b/backend/internal/cli/mysql_routines_test.go @@ -0,0 +1,50 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestMySQLRoutineHeaderEnd(t *testing.T) { + for _, header := range []string{ + "CREATE DEFINER=`user`@`host` FUNCTION `name`", + "CREATE DEFINER=`u(s``er`@`host` PROCEDURE `na(me`", + `CREATE DEFINER="u(ser"@"host" FUNCTION "na""me"`, + } { + end, err := mysqlRoutineHeaderEnd(header + "(IN value INT) BEGIN SELECT 1; END") + if err != nil || end != len(header) { + t.Fatalf("声明头边界不正确:%d,%v", end, err) + } + } + if _, err := mysqlRoutineHeaderEnd("CREATE FUNCTION `unterminated()"); err == nil || !strings.Contains(err.Error(), "参数列表") { + t.Fatal("不完整声明头必须失败") + } +} + +func TestMySQLRoutineVisibilityGrants(t *testing.T) { + for _, item := range []struct { + name string + grants []string + foldCase bool + want bool + }{ + {"show_routine", []string{"GRANT SHOW_ROUTINE ON *.* TO 'u'@'h'"}, false, true}, + {"global_select", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "GRANT BACKUP_ADMIN ON *.* TO 'u'@'h'"}, false, true}, + {"static_all", []string{"GRANT ALL PRIVILEGES ON *.* TO 'u'@'h'"}, false, true}, + {"schema_select", []string{"GRANT SELECT ON `sample`.* TO 'u'@'h'"}, false, false}, + {"schema_all", []string{"GRANT ALL PRIVILEGES ON `sample`.* TO 'u'@'h'"}, false, false}, + {"role_name", []string{"GRANT `SHOW_ROUTINE`@`%` TO 'u'@'h'"}, false, false}, + {"execute_only", []string{"GRANT EXECUTE, CREATE ROUTINE ON *.* TO 'u'@'h'"}, false, false}, + {"partial_revoke", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "REVOKE SELECT ON `sample`.* FROM 'u'@'h'"}, false, false}, + {"revoke_other_schema", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "REVOKE SELECT ON `other`.* FROM 'u'@'h'"}, false, true}, + {"show_not_revoked", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "REVOKE SELECT ON `sample`.* FROM 'u'@'h'", "GRANT SHOW_ROUTINE ON *.* TO 'u'@'h'"}, false, true}, + {"fold_revoke", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "REVOKE SELECT ON `SAMPLE`.* FROM 'u'@'h'"}, true, false}, + {"case_sensitive", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "REVOKE SELECT ON `SAMPLE`.* FROM 'u'@'h'"}, false, true}, + } { + t.Run(item.name, func(t *testing.T) { + if got := mysqlRoutineVisibilityGranted(item.grants, "sample", item.foldCase); got != item.want { + t.Fatalf("例程可见权限判定错误:得到 %t,期望 %t", got, item.want) + } + }) + } +} diff --git a/backend/internal/cli/mysql_stored_definition.go b/backend/internal/cli/mysql_stored_definition.go new file mode 100644 index 0000000..cf04120 --- /dev/null +++ b/backend/internal/cli/mysql_stored_definition.go @@ -0,0 +1,94 @@ +package cli + +import ( + "context" + "database/sql" + "fmt" + "io" + "strings" +) + +func readMySQLStoredDefinition(ctx context.Context, tx *sql.Tx, kind, name, statementColumn string) (map[string]string, error) { + rows, err := tx.QueryContext(ctx, "SHOW CREATE "+kind+" "+quoteMySQLIdentifier(name)) + if err != nil { + return nil, fmt.Errorf("读取 MySQL 存储对象定义失败(%s %s):%w", kind, name, err) + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + return nil, err + } + values := make([]sql.NullString, len(columns)) + pointers := make([]any, len(columns)) + for i := range values { + pointers[i] = &values[i] + } + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, err + } + return nil, fmt.Errorf("MySQL 存储对象定义不可见(%s %s)", kind, name) + } + if err := rows.Scan(pointers...); err != nil { + return nil, err + } + definition := make(map[string]string, len(columns)) + for i, column := range columns { + if values[i].Valid { + definition[column] = values[i].String + } + } + for _, column := range []string{"sql_mode", statementColumn, "character_set_client", "collation_connection", "Database Collation"} { + value, exists := definition[column] + if !exists || (value == "" && column != "sql_mode") { + return nil, fmt.Errorf("MySQL 存储对象定义字段不完整(%s %s):%s", kind, name, column) + } + } + if rows.Next() { + return nil, fmt.Errorf("MySQL 存储对象定义返回了多行(%s %s)", kind, name) + } + return definition, rows.Err() +} + +func writeMySQLStoredDefinition(ctx context.Context, tx *sql.Tx, output io.Writer, definition map[string]string, column, drop string) error { + charset := quoteMySQLIdentifier(definition["character_set_client"]) + encode := func(original string) (string, error) { + var encoded, roundTrip []byte + query := "SELECT CAST(CONVERT(? USING " + charset + ") AS BINARY), CAST(CONVERT(CONVERT(? USING " + charset + ") USING utf8mb4) AS BINARY)" + if err := tx.QueryRowContext(ctx, query, original, original).Scan(&encoded, &roundTrip); err != nil { + return "", fmt.Errorf("转换 MySQL 存储对象字符集失败:%w", err) + } + if string(roundTrip) != original { + return "", fmt.Errorf("MySQL 存储对象无法无损转换到创建字符集") + } + return string(encoded), nil + } + // 删除和创建语句均使用原客户端编码,非 ASCII 标识符不能混用 UTF-8 字节。 + encoded, err := encode(definition[column]) + if err != nil { + return err + } + encodedDrop := "" + if drop != "" { + encodedDrop, err = encode(drop) + if err != nil { + return err + } + } + delimiter := ";;" + for strings.Contains(encoded, delimiter) || strings.Contains(encodedDrop, delimiter) { + delimiter += ";" + } + if _, err := fmt.Fprintf(output, "ALTER DATABASE COLLATE %s;\nSET SESSION sql_mode = %s;\nSET SESSION character_set_client = %s;\nSET SESSION collation_connection = %s;\nDELIMITER %s\n", + quoteMySQLIdentifier(definition["Database Collation"]), mysqlStringLiteral(definition["sql_mode"]), + mysqlStringLiteral(definition["character_set_client"]), mysqlStringLiteral(definition["collation_connection"]), delimiter); err != nil { + return err + } + if encodedDrop != "" { + if _, err := fmt.Fprintf(output, "%s%s\n", encodedDrop, delimiter); err != nil { + return err + } + } + _, err = fmt.Fprintf(output, "%s%s\nDELIMITER ;\n", encoded, delimiter) + return err +} diff --git a/backend/internal/cli/mysql_triggers.go b/backend/internal/cli/mysql_triggers.go index 30db9ff..d97c32a 100644 --- a/backend/internal/cli/mysql_triggers.go +++ b/backend/internal/cli/mysql_triggers.go @@ -5,7 +5,6 @@ import ( "database/sql" "fmt" "io" - "strings" ) func dumpMySQLTriggers(ctx context.Context, tx *sql.Tx, output io.Writer) error { @@ -49,25 +48,7 @@ SET @scert_trigger_collation = @@SESSION.collation_connection;`); err != nil { if err != nil { return err } - // 文件中的创建语句使用原客户端字符集,恢复时才能同时保留正文和创建元数据。 - var encoded, roundTrip []byte - charset := quoteMySQLIdentifier(definition["character_set_client"]) - query := "SELECT CAST(CONVERT(? USING " + charset + ") AS BINARY), CAST(CONVERT(CONVERT(? USING " + charset + ") USING utf8mb4) AS BINARY)" - original := definition["SQL Original Statement"] - if err := tx.QueryRowContext(ctx, query, original, original).Scan(&encoded, &roundTrip); err != nil { - return fmt.Errorf("转换 MySQL 触发器字符集失败(%s):%w", name, err) - } - if string(roundTrip) != original { - return fmt.Errorf("MySQL 触发器无法无损转换到创建字符集(%s)", name) - } - delimiter := ";;" - for strings.Contains(string(encoded), delimiter) { - delimiter += ";" - } - _, err = fmt.Fprintf(output, "ALTER DATABASE COLLATE %s;\nSET SESSION sql_mode = %s;\nSET SESSION character_set_client = %s;\nSET SESSION collation_connection = %s;\nDELIMITER %s\n%s%s\nDELIMITER ;\n", - quoteMySQLIdentifier(definition["Database Collation"]), mysqlStringLiteral(definition["sql_mode"]), - mysqlStringLiteral(definition["character_set_client"]), mysqlStringLiteral(definition["collation_connection"]), delimiter, encoded, delimiter) - if err != nil { + if err := writeMySQLStoredDefinition(ctx, tx, output, definition, "SQL Original Statement", ""); err != nil { return fmt.Errorf("写入 MySQL 触发器失败(%s):%w", name, err) } } @@ -76,43 +57,5 @@ SET @scert_trigger_collation = @@SESSION.collation_connection;`); err != nil { } func readMySQLTrigger(ctx context.Context, tx *sql.Tx, name string) (map[string]string, error) { - rows, err := tx.QueryContext(ctx, "SHOW CREATE TRIGGER "+quoteMySQLIdentifier(name)) - if err != nil { - return nil, fmt.Errorf("读取 MySQL 触发器定义失败(%s):%w", name, err) - } - defer rows.Close() - columns, err := rows.Columns() - if err != nil { - return nil, err - } - values := make([]sql.NullString, len(columns)) - pointers := make([]any, len(columns)) - for i := range values { - pointers[i] = &values[i] - } - if !rows.Next() { - if err := rows.Err(); err != nil { - return nil, err - } - return nil, fmt.Errorf("MySQL 触发器定义不可见(%s)", name) - } - if err := rows.Scan(pointers...); err != nil { - return nil, err - } - definition := make(map[string]string, len(columns)) - for i, column := range columns { - if values[i].Valid { - definition[column] = values[i].String - } - } - for _, column := range []string{"sql_mode", "SQL Original Statement", "character_set_client", "collation_connection", "Database Collation"} { - value, exists := definition[column] - if !exists || (value == "" && column != "sql_mode") { - return nil, fmt.Errorf("MySQL 触发器定义字段不完整(%s):%s", name, column) - } - } - if rows.Next() { - return nil, fmt.Errorf("MySQL 触发器定义返回了多行(%s)", name) - } - return definition, rows.Err() + return readMySQLStoredDefinition(ctx, tx, "TRIGGER", name, "SQL Original Statement") } diff --git a/backend/internal/testsupport/mysqltest/server.go b/backend/internal/testsupport/mysqltest/server.go index 6ddd4b4..40cc57a 100644 --- a/backend/internal/testsupport/mysqltest/server.go +++ b/backend/internal/testsupport/mysqltest/server.go @@ -218,6 +218,14 @@ func (f *Server) Database(t *testing.T) config.DatabaseConfig { t.Fatal("创建隔离数据库及库级账户失败") } } + // 备份必须能发现其他定义者的例程,不能将过滤后的空列表当作完整目录。 + grant := "GRANT SELECT ON mysql.proc TO '" + user + "'@'127.0.0.1'" + if f.IsMySQL8() { + grant = "GRANT SHOW_ROUTINE ON *.* TO '" + user + "'@'127.0.0.1'" + } + if _, err := f.admin.ExecContext(t.Context(), grant); err != nil { + t.Fatal("授予隔离账户例程可见权限失败") + } return config.DatabaseConfig{Driver: "mysql", DSN: f.DSN(user, name), MaxOpenConns: 2, MaxIdleConns: 1} } diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index aed9962..7eb5349 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -347,3 +347,13 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 - `[ ]` 后续:例程、事件、视图、完整对象清单权限、其他 SQL 模式、复杂授权组合、定义者迁移、独立发行命令、Linux 实库、PostgreSQL、SQLite 及正式业务部署。 证据目录:`artifacts/mysql-native-triggers-20260927/`,最终结果为 `mysql57-final.jsonl`、`mysql80-final.jsonl`;详见支持矩阵第十一节。全部 44 项范围不变,P0-05 保持 `[~]`,本批不作生产放行。 + +### 2026-09-27:P0-05 自有存储过程与函数备份 + +修改前检查点 `db7d132` 已推送。本批接续触发器子任务,全部 44 项和原验收标准不变。 + +- `[x]` 在独立 MySQL 5.7 实例复现例程遗漏,以及权限不足时仍成功覆盖原备份;复现证据为 `artifacts/mysql-native-routines-20260927/mysql57-before.jsonl`。 +- `[x]` 增加原始例程定义导出、同名替换、创建元数据与编码保留;复用触发器的存储对象读写逻辑,先恢复数据和例程,再创建触发器。 +- `[x]` 增加完整例程可见权限检查;5.7 检查 `mysql.proc` 完整读取权限,8.0 检查有效 `SHOW_ROUTINE` 或全局 `SELECT`,拒绝把隐藏对象当作不存在。 +- `[~]` 正式回归覆盖两种恢复入口、重复恢复、输入输出参数、Latin-1 名称/正文、元数据一致性、角色和部分撤销。Windows 双版本全专项、后端完整回归和交叉编译正在验证,结果以支持矩阵后续记录为准。 +- `[ ]` 事件、视图、完整表清单权限、其他 SQL 模式及剩余数据库/平台/生产门禁继续整改。 diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index d7cc658..1dcdbb5 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -81,6 +81,13 @@ - `[x]` 本批实现、最终复测、收尾与文档证据已核对;后端全量测试、vet、模块校验及 Linux 测试包交叉编译通过。细节见支持矩阵第十一节,不提升 P0-05 总状态,也不将交叉编译记为 Linux 实库通过。 - `[ ]` 例程、事件、视图、其他 SQL 模式、完整表清单权限及剩余三库/双平台矩阵;歧义通配授权目前保守失败,不能视为所有权限组合均完整支持。 +### P0-05 例程自有备份子任务进度 + +- `[x]` 复现并修复存储过程、函数遗漏;保留定义者、参数、SQL 模式、创建字符集与排序规则,支持替换同名例程。 +- `[x]` 不依赖过滤后的元数据空列表;完整查看权限不足时失败,保留已有备份并清理临时文件。 +- `[~]` 双版本正式实库回归、角色和部分撤销验证、完整后端回归及 Linux 测试包交叉编译。 +- `[ ]` 事件、视图、完整表清单权限、其他 SQL 模式、跨服务器定义者迁移及生产恢复验收。本子任务不提升 P0-05 总状态。 + ## P0-06 建立统一领域模型 - 任务编号:P0-06;任务名称:建立统一领域模型;优先级:P0。