🐛 fix(oceanbase): 解决 Oracle 租户 MySQL wire 下双引号被误解析与列元数据静默失败

- DSN 注入 sql_mode='ANSI_QUOTES':让元数据查询的 AS "OWNER" 与 ApplyChanges 的 "schema"."table" 在 MySQL wire 上被识别为标识符
- sql_mode 加入 mysql driver 参数白名单,避免被 mergeMySQLConnectionParam 过滤丢弃
- 加载 Oracle 列元数据失败不再静默,改为返回带 ALL_TAB_COLUMNS 诊断提示的明确错误
- 修复 stripOceanBaseConnectionParamsForCache 未剥离 # 片段导致与 resolveOceanBaseProtocolParam 行为不一致
- 锁定 mysql ParseDSN 对 sys@oracle001#cluster:p@ss 类租户凭据切分的 invariant,防止未来误加 url.QueryEscape
- 同步 OceanBase agent revision,强制旧 driver-agent 被运行时校验拒绝
This commit is contained in:
Syngnat
2026-05-14 12:09:19 +08:00
parent 17331ddbaa
commit f94a0429d5
8 changed files with 235 additions and 8 deletions

View File

@@ -86,6 +86,12 @@ func stripOceanBaseConnectionParamsForCache(raw string) string {
if text == "" {
return ""
}
if queryIndex := strings.Index(text, "?"); queryIndex >= 0 {
text = text[queryIndex+1:]
}
if hashIndex := strings.Index(text, "#"); hashIndex >= 0 {
text = text[:hashIndex]
}
values, err := url.ParseQuery(strings.TrimLeft(text, "?&"))
if err != nil {
return text

View File

@@ -0,0 +1,33 @@
package app
import (
"strings"
"testing"
)
func TestStripOceanBaseConnectionParamsForCacheTrimsFragment(t *testing.T) {
t.Parallel()
got := stripOceanBaseConnectionParamsForCache("protocol=oracle&PREFETCH_ROWS=5000#dev-note")
if strings.Contains(got, "dev-note") {
t.Fatalf("expected fragment removed, got %q", got)
}
if !strings.Contains(got, "PREFETCH_ROWS=5000") {
t.Fatalf("expected business param kept, got %q", got)
}
if strings.Contains(got, "protocol=") {
t.Fatalf("expected protocol param stripped, got %q", got)
}
}
func TestStripOceanBaseConnectionParamsForCacheTrimsLeadingQuestionMark(t *testing.T) {
t.Parallel()
got := stripOceanBaseConnectionParamsForCache("?protocol=oracle&timeout=10")
if strings.Contains(got, "protocol=") {
t.Fatalf("expected protocol param stripped, got %q", got)
}
if !strings.Contains(got, "timeout=10") {
t.Fatalf("expected timeout kept, got %q", got)
}
}

View File

@@ -5,7 +5,7 @@ package db
func init() {
optionalDriverAgentRevisions = map[string]string{
"mariadb": "src-1a1cc64f8f92d92b",
"oceanbase": "src-f6f19676bb5102d1",
"oceanbase": "src-b10df5902bf60a23",
"diros": "src-bcc78fa43671ade5",
"sphinx": "src-404765c2fda68c5f",
"sqlserver": "src-d9fba1eca0a27c49",

View File

@@ -120,6 +120,7 @@ var mysqlSupportedDriverParamNames = map[string]string{
"readtimeout": "readTimeout",
"rejectreadonly": "rejectReadOnly",
"serverpubkey": "serverPubKey",
"sql_mode": "sql_mode",
"timetruncate": "timeTruncate",
"timeout": "timeout",
"tls": "tls",

View File

@@ -249,6 +249,28 @@ func withoutOceanBaseProtocolParams(config connection.ConnectionConfig) connecti
return next
}
// ensureOceanBaseOracleANSIQuotes 在 ConnectionParams 中注入 sql_mode='ANSI_QUOTES'
// 让 OceanBase Oracle 租户通过 MySQL wire 连接时把双引号当作标识符引用Oracle 语义),
// 否则元数据查询的列别名 `AS "OWNER"` 和 ApplyChanges 的 `"schema"."table"` 会被当作字符串字面量。
// 用户已显式设置 sql_mode 时,追加 ANSI_QUOTES保留其它 mode。
func ensureOceanBaseOracleANSIQuotes(raw string) string {
values := connectionParamsFromText(raw)
if values == nil {
values = url.Values{}
}
existing := strings.TrimSpace(values.Get("sql_mode"))
if existing == "" {
values.Set("sql_mode", "'ANSI_QUOTES'")
return values.Encode()
}
if strings.Contains(strings.ToUpper(existing), "ANSI_QUOTES") {
return values.Encode()
}
trimmed := strings.Trim(existing, "'")
values.Set("sql_mode", "'"+trimmed+",ANSI_QUOTES'")
return values.Encode()
}
func isOceanBaseOracleTenantMySQLDriverError(err error) bool {
if err == nil {
return false
@@ -314,6 +336,9 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
candidateConfig.Host = host
candidateConfig.Port = port
candidateConfig.User, candidateConfig.Password = resolveMySQLCredential(runConfig, index)
if protocol == oceanBaseProtocolOracle {
candidateConfig.ConnectionParams = ensureOceanBaseOracleANSIQuotes(candidateConfig.ConnectionParams)
}
dsn, err := o.getDSN(candidateConfig)
if err != nil {
@@ -468,7 +493,10 @@ func (o *OceanBaseDB) applyOracleChangesMySQLWire(tableName string, changes conn
return fmt.Errorf("连接未打开")
}
columnTypeMap := o.oracle.loadColumnTypeMap(tableName)
columnTypeMap, err := o.oracle.loadColumnTypeMap(tableName)
if err != nil {
return fmt.Errorf("OceanBase Oracle 租户 %w", err)
}
tx, err := o.oracle.conn.Begin()
if err != nil {

View File

@@ -9,6 +9,8 @@ import (
"testing"
"GoNavi-Wails/internal/connection"
mysqlDriver "github.com/go-sql-driver/mysql"
)
func TestResolveOceanBaseProtocol(t *testing.T) {
@@ -214,6 +216,156 @@ func TestOceanBaseOracleApplyChangesUsesMySQLWirePlaceholders(t *testing.T) {
}
}
// OceanBase Oracle 租户用户名形如 SYS@oracle001#cluster_name密码也可能含 @ 等保留字符。
// 锁定 mysql driver ParseDSN 能正确切分 user/password避免未来重构 buildMySQLCompatibleDSN 时
// 误引入 url.QueryEscape 等会破坏认证的"修复"。
func TestOceanBaseOracleDSNParsesTenantCredentials(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config connection.ConnectionConfig
wantUser string
wantPass string
}{
{
name: "tenant user with @",
config: connection.ConnectionConfig{
Host: "127.0.0.1", Port: 2881,
User: "sys@oracle001", Password: "pass", Database: "ORCL",
},
wantUser: "sys@oracle001",
wantPass: "pass",
},
{
name: "tenant user with @ and #cluster + password with @",
config: connection.ConnectionConfig{
Host: "127.0.0.1", Port: 2881,
User: "sys@oracle001#cluster", Password: "p@ss", Database: "ORCL",
},
wantUser: "sys@oracle001#cluster",
wantPass: "p@ss",
},
}
ob := &OceanBaseDB{}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dsn, err := ob.getDSN(tt.config)
if err != nil {
t.Fatalf("getDSN error: %v", err)
}
cfg, err := mysqlDriver.ParseDSN(dsn)
if err != nil {
t.Fatalf("mysql ParseDSN failed for %q: %v", dsn, err)
}
if cfg.User != tt.wantUser {
t.Fatalf("user mismatch: got %q want %q (dsn=%q)", cfg.User, tt.wantUser, dsn)
}
if cfg.Passwd != tt.wantPass {
t.Fatalf("password mismatch: got %q want %q (dsn=%q)", cfg.Passwd, tt.wantPass, dsn)
}
if cfg.DBName != tt.config.Database {
t.Fatalf("database mismatch: got %q want %q", cfg.DBName, tt.config.Database)
}
})
}
}
func TestEnsureOceanBaseOracleANSIQuotesInjectsSqlMode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expect string
}{
{
name: "empty params",
input: "",
expect: "sql_mode=%27ANSI_QUOTES%27",
},
{
name: "existing params without sql_mode",
input: "PREFETCH_ROWS=5000",
expect: "sql_mode=%27ANSI_QUOTES%27",
},
{
name: "preserve user sql_mode and append ANSI_QUOTES",
input: "sql_mode='STRICT_TRANS_TABLES'",
expect: "sql_mode=%27STRICT_TRANS_TABLES%2CANSI_QUOTES%27",
},
{
name: "no-op when user already includes ANSI_QUOTES",
input: "sql_mode='ANSI_QUOTES,NO_AUTO_VALUE_ON_ZERO'",
expect: "sql_mode=%27ANSI_QUOTES%2CNO_AUTO_VALUE_ON_ZERO%27",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ensureOceanBaseOracleANSIQuotes(tt.input)
if !strings.Contains(got, tt.expect) {
t.Fatalf("ensureOceanBaseOracleANSIQuotes(%q) = %q, want substring %q", tt.input, got, tt.expect)
}
})
}
}
func TestOceanBaseOracleDSNContainsANSIQuotesSysVar(t *testing.T) {
t.Parallel()
cfg := connection.ConnectionConfig{
Type: "oceanbase",
Host: "127.0.0.1",
Port: 2881,
User: "SYS@oracle001#cluster",
Password: "p@ss",
Database: "ORCL",
OceanBaseProtocol: "oracle",
}
cfg.ConnectionParams = ensureOceanBaseOracleANSIQuotes(cfg.ConnectionParams)
ob := &OceanBaseDB{}
dsn, err := ob.getDSN(cfg)
if err != nil {
t.Fatalf("getDSN error: %v", err)
}
if !strings.Contains(dsn, "sql_mode=%27ANSI_QUOTES%27") {
t.Fatalf("expected DSN to carry sql_mode='ANSI_QUOTES', got %q", dsn)
}
}
func TestOceanBaseOracleApplyChangesFailsLoudOnColumnMetadataError(t *testing.T) {
t.Parallel()
dbConn, state := openOracleRecordingDB(t)
state.queryError = errors.New("ORA-00942: table or view does not exist")
oceanbaseDB := &OceanBaseDB{}
oceanbaseDB.bindConnectedDatabase(dbConn, 0, oceanBaseProtocolOracle)
changes := connection.ChangeSet{
Updates: []connection.UpdateRow{{
Keys: map[string]interface{}{"ID": 7},
Values: map[string]interface{}{"NAME": "x"},
}},
}
err := oceanbaseDB.ApplyChanges("APP.USERS", changes)
if err == nil {
t.Fatal("expected error when column metadata load fails, got nil")
}
if !strings.Contains(err.Error(), "加载列元数据失败") {
t.Fatalf("expected error message to mention column metadata, got %v", err)
}
if !strings.Contains(err.Error(), "ORA-00942") {
t.Fatalf("expected error to wrap underlying ORA-00942, got %v", err)
}
}
func TestFormatOceanBaseMySQLAttemptErrorHintsOracleProtocol(t *testing.T) {
t.Parallel()

View File

@@ -29,6 +29,7 @@ type oracleRecordingState struct {
execArgs [][]driver.NamedValue
rowsAffected int64
queryResults map[string]oracleRecordingQueryResult
queryError error
}
type oracleRecordingQueryResult struct {
@@ -87,6 +88,10 @@ func (c *oracleRecordingConn) ExecContext(_ context.Context, query string, args
func (c *oracleRecordingConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
c.state.mu.Lock()
if err := c.state.queryError; err != nil {
c.state.mu.Unlock()
return nil, err
}
if result, ok := c.state.queryResults[query]; ok {
c.state.mu.Unlock()
return &oracleRecordingRows{

View File

@@ -515,17 +515,16 @@ func splitOracleQualifiedTableName(raw string) (string, string) {
return schema, table
}
func (o *OracleDB) loadColumnTypeMap(tableName string) map[string]string {
func (o *OracleDB) loadColumnTypeMap(tableName string) (map[string]string, error) {
result := map[string]string{}
schema, table := splitOracleQualifiedTableName(tableName)
if table == "" {
return result
return result, nil
}
columns, err := o.GetColumns(schema, table)
if err != nil {
logger.Warnf("加载 Oracle 列元数据失败(不影响提交):表=%s err=%v", tableName, err)
return result
return nil, fmt.Errorf("加载列元数据失败(表=%s%w请检查 ALL_TAB_COLUMNS 查询权限与表是否存在", tableName, err)
}
for _, col := range columns {
@@ -535,7 +534,7 @@ func (o *OracleDB) loadColumnTypeMap(tableName string) map[string]string {
}
result[name] = strings.TrimSpace(col.Type)
}
return result
return result, nil
}
func normalizeOracleValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} {
@@ -614,7 +613,10 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
return fmt.Errorf("连接未打开")
}
columnTypeMap := o.loadColumnTypeMap(tableName)
columnTypeMap, err := o.loadColumnTypeMap(tableName)
if err != nil {
return err
}
tx, err := o.conn.Begin()
if err != nil {