diff --git a/docs/db2.md b/docs/db2.md index d6be1211..27254607 100644 --- a/docs/db2.md +++ b/docs/db2.md @@ -110,12 +110,20 @@ Query parameters are forwarded as additional DB2 connection keywords (`HOSTNAME`, `DATABASE`, `PORT`, `PROTOCOL`, `UID`, `PWD`) are rejected — use the native form below for full control. -DB2's native form is also accepted as-is: +DB2's native form is also accepted as-is (ODBC keywords are case-insensitive and may carry +spaces after each `;`): ``` HOSTNAME=localhost;PORT=50000;DATABASE=TESTDB;UID=db2inst1;PWD=pass123;PROTOCOL=TCPIP ``` +The native form is self-contained: it already carries the host, port, credentials, params and +target database. It is therefore mutually exclusive with the structured `connect` fields +(`host`, `port`, `user`, `password`, `params`) and with a per-database override (`connect.database` +or the `databases` block for multi-database sync). Combining them is rejected with an explicit +error rather than silently ignoring the extra settings, so use the `db2://` URL form when you +need multi-database discovery or want to supply fields separately. + ## Writing a Db2 spec Db2 needs two things in every spec. Other engines need them only in spots (Oracle folds diff --git a/pkg/bsql/offline_validate.go b/pkg/bsql/offline_validate.go index 04f4dad5..2935f524 100644 --- a/pkg/bsql/offline_validate.go +++ b/pkg/bsql/offline_validate.go @@ -5,8 +5,13 @@ import ( "fmt" "net/url" "strings" + + "github.com/conductorone/baton-sql/pkg/database/db2" ) +// db2Scheme is the "db2" scheme name a native DB2 DSN classifies to in resolveConnectScheme. +const db2Scheme = "db2" + // OfflineValidate performs YAML-level structural checks without opening a DB or // requiring SQLSyncer. Suitable for editor/RPC offline validation. func OfflineValidate(cfg *Config) error { @@ -102,6 +107,11 @@ func resolveConnectScheme(c *DatabaseConfig) (string, error) { if dsn == "" { return "", errors.New("connect: scheme or dsn is required") } + // A native DB2 DSN carries no scheme prefix, so classify it via the shared detector to + // match pkg/database's routing and avoid misreading a "://" inside a value as a scheme. + if db2.IsNativeDSN(dsn) { + return db2Scheme, nil + } // Placeholders like postgres://${HOST}/db — peel scheme before parse when possible. if idx := strings.Index(dsn, "://"); idx > 0 { return strings.ToLower(dsn[:idx]), nil diff --git a/pkg/bsql/offline_validate_test.go b/pkg/bsql/offline_validate_test.go index 430e0be3..ffdfe2ee 100644 --- a/pkg/bsql/offline_validate_test.go +++ b/pkg/bsql/offline_validate_test.go @@ -124,6 +124,27 @@ func TestRejectNonV1_NonPostgresScheme(t *testing.T) { require.Contains(t, strings.ToLower(err.Error()), "postgres") } +func TestRejectNonV1_NativeDB2DSNRejectedAsDB2(t *testing.T) { + // A native DB2 DSN carries no scheme prefix, so it must classify as "db2" via the shared + // detector — giving v1 the right rejection message instead of a confusing "scheme + // missing" error — and must not be misclassified by a "://" inside a value. + for _, dsn := range []string{ + "HOSTNAME=localhost;PORT=50000;DATABASE=TESTDB;UID=u;PWD=p;PROTOCOL=TCPIP", + "HOSTNAME=localhost;DATABASE=TESTDB;PWD=my://secret", + } { + cfg, err := Parse([]byte(minimalPostgresYAML())) + require.NoError(t, err) + cfg.Connect.Scheme = "" + cfg.Connect.DSN = dsn + s, err := resolveConnectScheme(&cfg.Connect) + require.NoError(t, err) + require.Equal(t, "db2", s) + err = RejectNonV1ProductFeatures(cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "db2") + } +} + func TestRejectNonV1_PostgresqlAliasRejected(t *testing.T) { cfg, err := Parse([]byte(minimalPostgresYAML())) require.NoError(t, err) diff --git a/pkg/database/autherror.go b/pkg/database/autherror.go index e1c67cc4..16aaf85c 100644 --- a/pkg/database/autherror.go +++ b/pkg/database/autherror.go @@ -2,39 +2,39 @@ package database import ( "errors" + "fmt" "strings" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/conductorone/baton-sql/pkg/database/db2" "github.com/go-sql-driver/mysql" "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) const mysqlAccessDenied = 1045 -// AuthError returns an Unauthenticated gRPC status when err is a database -// authentication/authorization failure, or nil otherwise. name identifies the failing -// database so a multi-DB config still shows which handle rejected the credentials. -// SQLSTATE class 28 ("invalid authorization") is the ANSI code drivers report on bad -// credentials (Postgres/Redshift/Vertica/etc. surface it via SQLState()); MySQL is the -// exception, reporting error 1045 with no SQLSTATE. -// -// Coverage is limited to drivers that expose SQLState() plus MySQL. Drivers that do not -// (Oracle go-ora, Db2 go_ibm_db, MSSQL, SAP HDB) fall through to nil, so their auth -// failures reach the caller as a generic ping error rather than Unauthenticated. +// AuthError wraps err in an Unauthenticated gRPC status naming the failing database when +// err is a database auth failure, or returns nil otherwise, preserving the original error +// via errors.As. Detection covers SQLSTATE class 28 (Postgres/Redshift), MySQL error 1045, +// and DB2 (db2.IsAuthError); drivers without any of these (Vertica, Oracle, MSSQL, SAP HDB) +// fall through to a generic ping error. func AuthError(err error, name string) error { - if err == nil { + if err == nil || !isAuthFailure(err) { return nil } + return uhttp.WrapErrors(codes.Unauthenticated, fmt.Sprintf("database %q authentication failed", name), err) +} +func isAuthFailure(err error) bool { var sqlState interface{ SQLState() string } if errors.As(err, &sqlState) && strings.HasPrefix(sqlState.SQLState(), "28") { - return status.Errorf(codes.Unauthenticated, "database %q authentication failed", name) + return true } var myErr *mysql.MySQLError if errors.As(err, &myErr) && myErr.Number == mysqlAccessDenied { - return status.Errorf(codes.Unauthenticated, "database %q authentication failed", name) + return true } - return nil + return db2.IsAuthError(err) } diff --git a/pkg/database/autherror_test.go b/pkg/database/autherror_test.go index 883e3ff0..e4f2693b 100644 --- a/pkg/database/autherror_test.go +++ b/pkg/database/autherror_test.go @@ -3,6 +3,7 @@ package database import ( "errors" "fmt" + "strings" "testing" "github.com/go-sql-driver/mysql" @@ -39,6 +40,12 @@ func TestAuthError(t *testing.T) { if status.Code(got) != tt.want { t.Fatalf("want %v, got %v", tt.want, status.Code(got)) } + if !strings.Contains(got.Error(), `"testdb"`) { + t.Fatalf("expected database name in error, got %v", got) + } + if !errors.Is(got, tt.err) { + t.Fatalf("expected original error to remain reachable via errors.Is, got %v", got) + } }) } } diff --git a/pkg/database/database.go b/pkg/database/database.go index 005164ef..0c540075 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -18,6 +18,8 @@ import ( "github.com/conductorone/baton-sql/pkg/database/postgres" "github.com/conductorone/baton-sql/pkg/database/sqlserver" "github.com/conductorone/baton-sql/pkg/database/vertica" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) var DSNREnvRegex = regexp.MustCompile(`\$\{([A-Za-z0-9_]+)\}`) @@ -361,6 +363,9 @@ func ResolveDatabaseName(opts ConnectOptions) string { return expanded } } + if _, database, isNativeDB2, err := nativeDB2DSN(opts); err == nil && isNativeDB2 { + return database + } parsedUrl, err := buildConnectionURL(opts) if err != nil || parsedUrl == nil { return "" @@ -368,6 +373,15 @@ func ResolveDatabaseName(opts ConnectOptions) string { return strings.TrimPrefix(parsedUrl.Path, "/") } +// hasStructuredConnectFields reports whether opts sets any structured connect field a +// self-contained native DB2 DSN would silently override; the caller rejects that +// combination instead of dropping the fields. Scheme is excluded, since "db2" alongside +// a native DSN is a supported hint. +func hasStructuredConnectFields(opts ConnectOptions) bool { + return opts.Host != "" || opts.Port != "" || opts.User != "" || + opts.Password != "" || opts.Database != "" || len(opts.Params) > 0 +} + // ConnectMany opens one *sql.DB per name in dbNames. On any per-database failure, // every handle opened so far is closed before returning the error. func ConnectMany(ctx context.Context, opts ConnectOptions, dbNames []string) (map[string]*sql.DB, DbEngine, error) { @@ -404,13 +418,38 @@ func ConnectMany(ctx context.Context, opts ConnectOptions, dbNames []string) (ma } func Connect(ctx context.Context, opts ConnectOptions) (*sql.DB, DbEngine, error) { + // A native DB2 DSN is opaque ODBC text, not a URL, so hand it to the driver verbatim + // instead of routing it through buildConnectionURL, which would corrupt it. See docs/db2.md. + nativeDSN, _, isNativeDB2, err := nativeDB2DSN(opts) + if err != nil { + return nil, Unknown, err + } + if isNativeDB2 { + // A native DSN already carries every connection setting, so structured fields or + // a per-database override would be silently dropped on the verbatim path; reject + // the combination instead of connecting to the wrong database. See docs/db2.md. + if hasStructuredConnectFields(opts) { + return nil, Unknown, status.Error(codes.InvalidArgument, + "native DB2 DSN is self-contained and cannot be combined with structured "+ + "connect fields (host, port, user, password, params) or a per-database "+ + "override (connect.database, databases); put every setting in the DSN or "+ + "use the db2:// URL form", + ) + } + db, err := db2.Connect(ctx, nativeDSN) + if err != nil { + return nil, Unknown, err + } + return db, DB2, nil + } + parsedDsn, err := buildConnectionURL(opts) if err != nil { return nil, Unknown, err } if parsedDsn.Scheme == "" { - return nil, Unknown, errors.New("database scheme must be specified in DSN or configuration") + return nil, Unknown, status.Error(codes.InvalidArgument, "database scheme must be specified in DSN or configuration") } switch parsedDsn.Scheme { @@ -456,7 +495,7 @@ func Connect(ctx context.Context, opts ConnectOptions) (*sql.DB, DbEngine, error } return db, Vertica, nil - case "db2": + case db2Scheme: db, err := db2.Connect(ctx, parsedDsn.String()) if err != nil { return nil, Unknown, err @@ -468,6 +507,45 @@ func Connect(ctx context.Context, opts ConnectOptions) (*sql.DB, DbEngine, error } } +// db2Scheme is the "db2" scheme name, used both as the switch case above and to +// recognize an explicit (rather than inferred) DB2 hint in nativeDB2DSN. +const db2Scheme = "db2" + +// nativeDB2DSN reports whether opts carries a native (ODBC keyword=value) DB2 DSN rather +// than a db2:// URL, returning the env-expanded DSN and its DATABASE value when it does. +// Detection defers to db2.ParseNativeDSN so this and convertToDB2DSN's passthrough share +// one decision. +func nativeDB2DSN(opts ConnectOptions) (string, string, bool, error) { + if opts.DSN == "" { + return "", "", false, nil + } + lookup := opts.resolveLookup() + + scheme, err := expandValue(opts.Scheme, lookup) + if err != nil { + return "", "", false, err + } + if scheme != "" && scheme != db2Scheme { + return "", "", false, nil + } + + dsn, err := expandValue(opts.DSN, lookup) + if err != nil { + return "", "", false, err + } + if _, native := db2.ParseNativeDSN(dsn); !native { + return "", "", false, nil + } + // Confirmed native: re-expand with keyword-injection validation. The expansion above + // only decides routing; the driver gets this string verbatim. + safeDSN, err := expandNativeDSN(opts.DSN, lookup) + if err != nil { + return "", "", false, err + } + database, _ := db2.ParseNativeDSN(safeDSN) + return safeDSN, database, true, nil +} + func buildConnectionURL(opts ConnectOptions) (*url.URL, error) { var ( parsedUrl *url.URL @@ -605,3 +683,39 @@ func expandValue(s string, lookup LookupFunc) (string, error) { } return s, nil } + +// expandNativeDSN expands ${KEY} placeholders in a native DB2 DSN, rejecting any value +// containing an ODBC separator (; { } =); unlike the db2:// URL path, which quotes each +// field via quoteDB2Value, a native DSN reaches the driver verbatim, so an unchecked +// placeholder could inject or override keywords. +func expandNativeDSN(dsn string, lookup LookupFunc) (string, error) { + if !DSNREnvRegex.MatchString(dsn) { + return dsn, nil + } + // A DSN that is a single ${KEY} spanning the whole string is the full value, not a + // field embedded in literal structure, so its separators are legitimate: expand as-is. + if DSNREnvRegex.FindString(dsn) == dsn { + return expandValue(dsn, lookup) + } + if lookup == nil { + lookup = os.LookupEnv + } + var err error + result := DSNREnvRegex.ReplaceAllStringFunc(dsn, func(match string) string { + varName := match[2 : len(match)-1] + value, exists := lookup(varName) + if !exists { + err = errors.Join(err, fmt.Errorf("environment variable %s is not set", varName)) + return match + } + if strings.ContainsAny(value, ";{}=") { + err = errors.Join(err, fmt.Errorf("value for %s must not contain ODBC keyword separators (; { } =)", varName)) + return match + } + return value + }) + if err != nil { + return "", err + } + return result, nil +} diff --git a/pkg/database/db2/autherror_test.go b/pkg/database/db2/autherror_test.go new file mode 100644 index 00000000..29442b84 --- /dev/null +++ b/pkg/database/db2/autherror_test.go @@ -0,0 +1,33 @@ +//go:build db2 + +package db2 + +import ( + "fmt" + "testing" + + "github.com/ibmdb/go_ibm_db" + "github.com/stretchr/testify/require" +) + +func TestIsAuthError(t *testing.T) { + badCreds := &go_ibm_db.Error{Diag: []go_ibm_db.DiagRecord{{State: "28000"}}} + + tests := []struct { + name string + err error + want bool + }{ + {"class 28 bad credentials", badCreds, true}, + {"class 28 wrapped", fmt.Errorf("connect: %w", badCreds), true}, + {"non-auth sqlstate", &go_ibm_db.Error{Diag: []go_ibm_db.DiagRecord{{State: "42501"}}}, false}, + {"no diag records", &go_ibm_db.Error{}, false}, + {"unrelated error", fmt.Errorf("boom"), false}, + {"nil", nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, IsAuthError(tt.err)) + }) + } +} diff --git a/pkg/database/db2/db2.go b/pkg/database/db2/db2.go index 1b942c65..94a5144c 100644 --- a/pkg/database/db2/db2.go +++ b/pkg/database/db2/db2.go @@ -5,9 +5,11 @@ package db2 import ( "context" "database/sql" + "errors" + "strings" "time" - _ "github.com/ibmdb/go_ibm_db" + "github.com/ibmdb/go_ibm_db" ) // Connect establishes a connection to DB2 database. @@ -35,3 +37,18 @@ func Connect(ctx context.Context, dsn string) (*sql.DB, error) { return db, nil } + +// IsAuthError reports whether err is a DB2 auth failure. go_ibm_db exposes SQLSTATE +// via Error.Diag[].State, not a SQLState() method, so class 28 must be matched here. +func IsAuthError(err error) bool { + var db2Err *go_ibm_db.Error + if !errors.As(err, &db2Err) { + return false + } + for _, rec := range db2Err.Diag { + if strings.HasPrefix(rec.State, "28") { + return true + } + } + return false +} diff --git a/pkg/database/db2/db2_stub.go b/pkg/database/db2/db2_stub.go index 389b6b59..418e2f6f 100644 --- a/pkg/database/db2/db2_stub.go +++ b/pkg/database/db2/db2_stub.go @@ -14,3 +14,8 @@ import ( func Connect(_ context.Context, _ string) (*sql.DB, error) { return nil, errors.New("baton-sql: DB2 support not compiled into this binary; rebuild with -tags db2 (see docs/db2.md)") } + +// IsAuthError is a stub; the DB2 driver error types are unavailable without -tags db2. +func IsAuthError(_ error) bool { + return false +} diff --git a/pkg/database/db2/dsn.go b/pkg/database/db2/dsn.go index 87b7d3ed..278abc7e 100644 --- a/pkg/database/db2/dsn.go +++ b/pkg/database/db2/dsn.go @@ -3,10 +3,97 @@ package db2 import ( "fmt" "net/url" + "regexp" "sort" "strings" ) +// urlSchemeRegex matches a DSN that begins with a URL scheme (e.g. "db2://"); anchoring +// to the start keeps a native DSN whose value contains "://" (e.g. PWD=my://secret) from +// being misread as a URL. +var urlSchemeRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`) + +// ParseNativeDSN reports whether dsn is DB2's native ODBC keyword=value form (not a URL), +// returning its DATABASE value if present. HOSTNAME, not DATABASE alone, is the native +// marker, since other engines' ODBC/ADO strings also carry DATABASE; every caller shares +// this one detector to avoid drift. +func ParseNativeDSN(dsn string) (string, bool) { + if urlSchemeRegex.MatchString(dsn) { + return "", false + } + var database string + native, haveDB := false, false + for _, part := range splitDB2DSN(dsn) { + keyword, value, found := strings.Cut(part, "=") + if !found { + continue + } + keyword = strings.TrimSpace(keyword) + switch { + case strings.EqualFold(keyword, "HOSTNAME"): + native = true + case strings.EqualFold(keyword, "DATABASE"): + if !haveDB { // first DATABASE= wins + value = strings.TrimSpace(value) + if strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}") { + value = value[1 : len(value)-1] + } + database, haveDB = value, true + } + } + } + return database, native +} + +// IsNativeDSN reports whether dsn is DB2's native ODBC keyword=value form. +func IsNativeDSN(dsn string) bool { + _, native := ParseNativeDSN(dsn) + return native +} + +// DSNDatabase returns the DATABASE keyword value from a native DB2 DSN, or "" if absent. +func DSNDatabase(dsn string) string { + database, _ := ParseNativeDSN(dsn) + return database +} + +// splitDB2DSN splits a native DB2 DSN on ';', treating '{' as ODBC quoting only when it +// opens a value and is later closed by '}'; an unterminated or misplaced '{' is literal, +// so HOSTNAME/DATABASE markers stay visible instead of being silently swallowed. +func splitDB2DSN(dsn string) []string { + var parts []string + start := 0 + braced := false // inside a {...} quoted value + atValueStart := false // at a value position (right after '=', across whitespace) outside braces + for i := 0; i < len(dsn); i++ { + switch dsn[i] { + case '}': + braced = false + atValueStart = false + case '{': + if atValueStart && strings.IndexByte(dsn[i:], '}') != -1 { + braced = true + } + atValueStart = false + case '=': + if !braced { + atValueStart = true + } + case ';': + if !braced { + parts = append(parts, dsn[start:i]) + start = i + 1 + } + atValueStart = false + case ' ', '\t': + // keep atValueStart so "DATABASE= {my;db}" still brace-detects. + default: + atValueStart = false + } + } + return append(parts, dsn[start:]) +} + // Keywords derived from the URL itself; query parameters may not override them. // Anyone needing full control over these can pass a native DB2 DSN instead. var reservedDSNKeywords = map[string]bool{ @@ -33,9 +120,9 @@ func quoteDB2Value(v string) (string, error) { // convertToDB2DSN converts URL format to DB2 DSN format. func convertToDB2DSN(dsn string) (string, error) { - // If it's already in DB2 format (contains HOSTNAME= or DATABASE=), return as-is. - // URL-format DSNs are exempt from this check so those markers may appear in credentials. - if !strings.HasPrefix(dsn, "db2://") && (strings.Contains(dsn, "HOSTNAME=") || strings.Contains(dsn, "DATABASE=")) { + // If it's already in DB2's native keyword=value format, return as-is. + // URL-format DSNs are exempt so those markers may appear in credentials. + if IsNativeDSN(dsn) { return dsn, nil } diff --git a/pkg/database/db2/dsn_test.go b/pkg/database/db2/dsn_test.go index 9ab7956a..a3c0b776 100644 --- a/pkg/database/db2/dsn_test.go +++ b/pkg/database/db2/dsn_test.go @@ -28,6 +28,16 @@ func TestConvertToDB2DSN(t *testing.T) { dsn: "HOSTNAME=dbhost;PORT=50000;DATABASE=testdb;UID=user;PWD=pass", want: "HOSTNAME=dbhost;PORT=50000;DATABASE=testdb;UID=user;PWD=pass", }, + { + name: "lowercase native dsn passed through", + dsn: "hostname=dbhost;port=50000;database=testdb;uid=user;pwd=pass", + want: "hostname=dbhost;port=50000;database=testdb;uid=user;pwd=pass", + }, + { + name: "native dsn with whitespace passed through", + dsn: "HOSTNAME=dbhost; DATABASE=testdb; UID=user", + want: "HOSTNAME=dbhost; DATABASE=testdb; UID=user", + }, { name: "wrong scheme", dsn: "postgres://dbhost/testdb", @@ -102,3 +112,58 @@ func TestConvertToDB2DSN(t *testing.T) { }) } } + +func TestIsNativeDSN(t *testing.T) { + tests := []struct { + name string + dsn string + want bool + }{ + {name: "native markers", dsn: "HOSTNAME=h;DATABASE=X", want: true}, + {name: "lowercase keywords", dsn: "hostname=h;database=x", want: true}, + {name: "whitespace after separator", dsn: "HOSTNAME=h; DATABASE=X", want: true}, + {name: "db2 url", dsn: "db2://u:p@h:50000/db", want: false}, + {name: "postgres url", dsn: "postgres://h/db", want: false}, + {name: "value carrying :// is not a url", dsn: "HOSTNAME=h;PWD=my://secret", want: true}, + {name: "space before the =", dsn: "HOSTNAME = h;DATABASE=X", want: true}, + // DATABASE without HOSTNAME is a generic ODBC/ADO shape (e.g. MSSQL), not native DB2. + {name: "database without hostname is not native", dsn: "Server=x;Database=y;User Id=u", want: false}, + // HOSTNAME appears only inside a braced PWD value, so the brace-aware split keeps it + // as one PWD part: not a native marker. + {name: "hostname marker only inside braced value", dsn: "UID=u;PWD={x;HOSTNAME=y}", want: false}, + // Unterminated '{' is literal, so the ';' still splits and HOSTNAME= stays visible; + // the malformed value then reaches the driver instead of silently misrouting. + {name: "unterminated brace keeps marker visible", dsn: "PWD={oops;HOSTNAME=h", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, IsNativeDSN(tt.dsn)) + }) + } +} + +func TestDSNDatabase(t *testing.T) { + tests := []struct { + name string + dsn string + want string + }{ + {name: "plain", dsn: "HOSTNAME=h;DATABASE=TESTDB;UID=u", want: "TESTDB"}, + {name: "braced value with semicolon", dsn: "HOSTNAME=h;DATABASE={my;db}", want: "my;db"}, + {name: "lowercase", dsn: "hostname=h;database=testdb", want: "testdb"}, + {name: "whitespace before keyword", dsn: "HOSTNAME=h; DATABASE=TESTDB", want: "TESTDB"}, + {name: "space after the =", dsn: "HOSTNAME=h;DATABASE= TESTDB", want: "TESTDB"}, + {name: "space before the =", dsn: "HOSTNAME=h;DATABASE = TESTDB", want: "TESTDB"}, + // Space between '=' and a braced value must still brace-detect, else the ';' + // inside the braces splits and the database name comes back truncated. + {name: "space before braced value", dsn: "HOSTNAME=h;DATABASE= {my;db}", want: "my;db"}, + // A literal '{' mid-value (not ODBC quoting) must not swallow the following ';'. + {name: "unquoted brace in earlier value", dsn: "HOSTNAME=h;PWD=p{q;DATABASE=TESTDB", want: "TESTDB"}, + {name: "absent", dsn: "HOSTNAME=h;UID=u", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, DSNDatabase(tt.dsn)) + }) + } +} diff --git a/pkg/database/native_db2_dsn_test.go b/pkg/database/native_db2_dsn_test.go new file mode 100644 index 00000000..b5afda9c --- /dev/null +++ b/pkg/database/native_db2_dsn_test.go @@ -0,0 +1,151 @@ +package database + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNativeDB2DSN(t *testing.T) { + const native = "HOSTNAME=localhost;PORT=50000;DATABASE=TESTDB;UID=db2inst1;PWD=pass123;PROTOCOL=TCPIP" + + lookup := func(m map[string]string) LookupFunc { + return func(k string) (string, bool) { v, ok := m[k]; return v, ok } + } + + tests := []struct { + name string + opts ConnectOptions + wantDSN string + wantOk bool + wantErr string + }{ + {name: "native form no scheme", opts: ConnectOptions{DSN: native}, wantDSN: native, wantOk: true}, + {name: "native form with scheme db2", opts: ConnectOptions{DSN: native, Scheme: "db2"}, wantDSN: native, wantOk: true}, + // DATABASE without HOSTNAME is a generic ODBC/ADO shape, not native DB2: it must + // fall through to the normal scheme check rather than route to the DB2 driver. + {name: "database marker only is not native", opts: ConnectOptions{DSN: "DATABASE=TESTDB;HOST=x"}, wantDSN: "", wantOk: false}, + { + name: "lowercase keywords", + opts: ConnectOptions{DSN: "hostname=h;port=50000;database=X;uid=u;pwd=p"}, + wantDSN: "hostname=h;port=50000;database=X;uid=u;pwd=p", + wantOk: true, + }, + { + name: "whitespace after separators", + opts: ConnectOptions{DSN: "HOSTNAME=h; DATABASE=X; UID=u"}, + wantDSN: "HOSTNAME=h; DATABASE=X; UID=u", + wantOk: true, + }, + { + name: "value containing :// is not a url", + opts: ConnectOptions{DSN: "HOSTNAME=h;DATABASE=X;PWD=my://secret"}, + wantDSN: "HOSTNAME=h;DATABASE=X;PWD=my://secret", + wantOk: true, + }, + { + name: "native form with placeholders", + opts: ConnectOptions{ + DSN: "HOSTNAME=${DB_HOST};PORT=50000;DATABASE=${DB_NAME};UID=u;PWD=p", + Lookup: lookup(map[string]string{"DB_HOST": "h", "DB_NAME": "d"}), + }, + wantDSN: "HOSTNAME=h;PORT=50000;DATABASE=d;UID=u;PWD=p", + wantOk: true, + }, + { + name: "scheme placeholder expands to db2", + opts: ConnectOptions{DSN: native, Scheme: "${SCH}", Lookup: lookup(map[string]string{"SCH": "db2"})}, + wantDSN: native, + wantOk: true, + }, + {name: "db2 url form", opts: ConnectOptions{DSN: "db2://u:p@h:50000/db"}, wantOk: false}, + {name: "postgres url form", opts: ConnectOptions{DSN: "postgres://h/db"}, wantOk: false}, + {name: "native markers but foreign scheme", opts: ConnectOptions{DSN: native, Scheme: "postgres"}, wantOk: false}, + {name: "url with database marker in query", opts: ConnectOptions{DSN: "db2://h:50000/db?DATABASE=x"}, wantOk: false}, + {name: "empty dsn", opts: ConnectOptions{}, wantOk: false}, + { + name: "unset placeholder errors", + opts: ConnectOptions{DSN: "HOSTNAME=${MISSING};DATABASE=d", Lookup: lookup(map[string]string{})}, + wantErr: "MISSING", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotDSN, _, gotOk, err := nativeDB2DSN(tt.opts) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantOk, gotOk) + require.Equal(t, tt.wantDSN, gotDSN) + }) + } +} + +func TestResolveDatabaseNameNativeDB2(t *testing.T) { + // The native form must resolve the same database name as the equivalent db2:// URL, + // so resource IDs stay stable across the two DSN forms. + tests := []struct { + name string + opts ConnectOptions + want string + }{ + { + name: "native form", + opts: ConnectOptions{DSN: "HOSTNAME=h;PORT=50000;DATABASE=TESTDB;UID=u;PWD=p;PROTOCOL=TCPIP"}, + want: "TESTDB", + }, + { + name: "native form braced database", + opts: ConnectOptions{DSN: "HOSTNAME=h;DATABASE={my;db};UID=u"}, + want: "my;db", + }, + { + name: "lowercase database keyword", + opts: ConnectOptions{DSN: "hostname=h;database=testdb;uid=u"}, + want: "testdb", + }, + { + name: "whitespace before database keyword", + opts: ConnectOptions{DSN: "HOSTNAME=h; DATABASE=TESTDB; UID=u"}, + want: "TESTDB", + }, + { + name: "equivalent url form", + opts: ConnectOptions{DSN: "db2://u:p@h:50000/TESTDB"}, + want: "TESTDB", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, ResolveDatabaseName(tt.opts)) + }) + } +} + +func TestExpandNativeDSN(t *testing.T) { + lookup := func(k string) (string, bool) { + m := map[string]string{"H": "dbhost", "PW": "secret", "BAD": "x;DATABASE=other"} + v, ok := m[k] + return v, ok + } + + got, err := expandNativeDSN("HOSTNAME=${H};PWD=${PW}", lookup) + require.NoError(t, err) + require.Equal(t, "HOSTNAME=dbhost;PWD=secret", got) + + // A placeholder value carrying ODBC separators can't inject extra keywords. + _, err = expandNativeDSN("HOSTNAME=${H};PWD=${BAD}", lookup) + require.ErrorContains(t, err, "ODBC keyword separators") + + // A single ${KEY} spanning the whole DSN is the full value, so its separators are kept. + whole := func(k string) (string, bool) { return "HOSTNAME=h;DATABASE=d", k == "DSN" } + got, err = expandNativeDSN("${DSN}", whole) + require.NoError(t, err) + require.Equal(t, "HOSTNAME=h;DATABASE=d", got) + + _, err = expandNativeDSN("HOSTNAME=${MISSING}", lookup) + require.ErrorContains(t, err, "is not set") +} diff --git a/pkg/database/native_db2_route_test.go b/pkg/database/native_db2_route_test.go new file mode 100644 index 00000000..31527f17 --- /dev/null +++ b/pkg/database/native_db2_route_test.go @@ -0,0 +1,65 @@ +//go:build !db2 + +package database + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// A native DB2 DSN must reach the DB2 driver, not the URL builder; on a default (non-db2) +// build, Connect should return the "not compiled" stub error, never the URL builder's +// scheme/database errors. +func TestConnectNativeDB2DSNReachesDriver(t *testing.T) { + const native = "HOSTNAME=localhost;PORT=50000;DATABASE=TESTDB;UID=db2inst1;PWD=pass123;PROTOCOL=TCPIP" + + for _, tt := range []struct { + name string + opts ConnectOptions + }{ + {name: "no scheme", opts: ConnectOptions{DSN: native}}, + {name: "scheme db2", opts: ConnectOptions{DSN: native, Scheme: "db2"}}, + } { + t.Run(tt.name, func(t *testing.T) { + _, _, err := Connect(context.Background(), tt.opts) + require.Error(t, err) + require.ErrorContains(t, err, "DB2 support not compiled") + require.NotContains(t, err.Error(), "scheme must be specified") + require.NotContains(t, err.Error(), "database name is required") + }) + } +} + +// A native DSN already carries every connection setting, so pairing it with structured +// fields or a per-database override must be rejected up front, never silently dropped — +// including via ConnectMany's per-name Database override. +func TestConnectNativeDB2DSNRejectsStructuredFields(t *testing.T) { + const native = "HOSTNAME=localhost;PORT=50000;DATABASE=TESTDB;UID=db2inst1;PWD=pass123;PROTOCOL=TCPIP" + + for _, tt := range []struct { + name string + opts ConnectOptions + }{ + {name: "database override", opts: ConnectOptions{DSN: native, Database: "OTHERDB"}}, + {name: "host", opts: ConnectOptions{DSN: native, Host: "elsewhere"}}, + {name: "port", opts: ConnectOptions{DSN: native, Port: "50001"}}, + {name: "user", opts: ConnectOptions{DSN: native, User: "someone"}}, + {name: "password", opts: ConnectOptions{DSN: native, Password: "secret"}}, + {name: "params", opts: ConnectOptions{DSN: native, Params: map[string]string{"SECURITY": "SSL"}}}, + } { + t.Run(tt.name, func(t *testing.T) { + _, _, err := Connect(context.Background(), tt.opts) + require.Error(t, err) + require.ErrorContains(t, err, "self-contained") + require.NotContains(t, err.Error(), "DB2 support not compiled") + }) + } + + t.Run("multi-database via ConnectMany", func(t *testing.T) { + _, _, err := ConnectMany(context.Background(), ConnectOptions{DSN: native}, []string{"A", "B"}) + require.Error(t, err) + require.ErrorContains(t, err, "self-contained") + }) +}