Skip to content

Commit 45ecd39

Browse files
adwk67claude
andcommitted
fix: SQLCopyDesc phase one runs under a panic guard
The forward_ffi! stub calls sql_copy_desc directly and phase one (the source-descriptor snapshot) had no catch_unwind above it, so a backend panic in describe_col during an IRD snapshot unwound out of the extern "system" boundary — a guaranteed host-process abort. Phase one's closure now catches the panic via panic::catch_panic_as_error and folds it into the existing error path, so it surfaces as HY000 on the target's diagnostic queue like every other caught panic, with no new lock site and the two-phase (never two group locks) structure intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 128abaf commit 45ecd39

5 files changed

Lines changed: 303 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,22 @@ ODBC Application (e.g. isql)
13161316
for a new entry point, and "it takes no handle" is a reason to reach for
13171317
`panic_safe_unlocked`, not a reason to skip the guard — `ConfigDSNW` had no
13181318
guard at all until 2026-07-30 on exactly that reasoning.
1319+
1320+
**`SQLCopyDesc` is the one export a single guard cannot cover, because it is
1321+
the one export that takes two lock phases rather than one** (see
1322+
"Descriptors" → "The explicit-descriptor rulings" above for why). Phase two
1323+
is an ordinary `panic_safe` on the target. Phase one holds only the
1324+
*source*'s group — through `HandleScope::with_group`, which is a plain
1325+
lock-then-call with no `catch_unwind` of its own — so a panic reaching
1326+
`describe_col` through `snapshot_ird` had no guard at all until 2026-07-31,
1327+
the same gap `ConfigDSNW` had. `panic::catch_panic_as_error` closes it:
1328+
narrower than `panic_safe_unlocked`, because it does not itself sit at the
1329+
FFI boundary — it converts the panic into the same `OdbcError` shape a
1330+
non-panicking phase-one failure (`HY007`) already returns, and phase two's
1331+
`panic_safe` posts it to the target's queue either way, which is where the
1332+
whole call's diagnostics belong. So `sql_copy_desc` is fully guarded, just not by
1333+
"one of the two" in the sense above — the property this bullet is
1334+
asserting one export needs a third shape to satisfy.
13191335
- **W-only for string-bearing functions**: every ODBC function that takes or
13201336
returns a string is exported only in its Wide (`W`-suffix) form; the Driver
13211337
Manager translates an ANSI application's calls into those. Functions with no
@@ -1367,7 +1383,7 @@ Generic framework. Zero database-specific code.
13671383
| `handles/scope.rs` | `HandleScope` — the only way to reach a handle's contents; token validation without dereferencing the application's pointer |
13681384
| `sync.rs` | The one import path for every lock in the crate; aliases to `loom`'s primitives under `#[cfg(all(loom, test))]`, `std::sync` otherwise |
13691385
| `utf16.rs` | `utf16_to_string`, `write_utf16` (ODBC uses UTF-16LE) |
1370-
| `panic.rs` | `panic_safe` (locks the target's group, builds a `HandleScope`, catches panics) and `panic_safe_unlocked` (`SQLCancel`'s lock-free sibling) |
1386+
| `panic.rs` | `panic_safe` (locks the target's group, builds a `HandleScope`, catches panics), `panic_safe_unlocked` (`SQLCancel`'s lock-free sibling), and `catch_panic_as_error` (`SQLCopyDesc` phase one's panic-to-`OdbcError` guard) |
13711387
| `logging.rs` | `init_logging()` via tracing, configured by `ODBC_LOG_LEVEL` / `ODBC_LOG_FILE` |
13721388
| `function_id.rs` | `FunctionId` enum + `function_id_from_raw()` for `SQL_API_*` constants |
13731389
| `test_support.rs` | `test-support`-feature-gated hooks a driver's test suite uses to put a connection into a handle without `SQLDriverConnectW` |

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3066,4 +3066,23 @@ call `SQLCloseCursor` or `SQLFreeStmt(SQL_CLOSE)` first, as it already must for
30663066
framing of the attribute as shifting a *buffer* — a pointer with no buffer
30673067
behind it has nothing to shift.
30683068

3069+
- **`SQLCopyDesc`'s phase one now runs under a panic guard.** It copies a
3070+
descriptor in two lock phases — the source's group alone, then the target's
3071+
— and only phase two was wrapped by `panic_safe`. Phase one calls
3072+
`describe_col` (via `snapshot_ird`) whenever the source is an IRD, which is
3073+
driver-author code and the exact panic surface every other `Backend` call
3074+
runs under a guard for; a panic there had no `catch_unwind` above it at all,
3075+
so it unwound straight through `HandleScope::with_group` and across the
3076+
`extern "system"` boundary `forward_ffi!` generates for
3077+
`SQLCopyDesc` — aborting the process rather than returning `SQL_ERROR`.
3078+
3079+
The fix is a new `panic::catch_panic_as_error`, narrower than
3080+
`panic_safe_unlocked`: phase one has no target handle to post a diagnostic
3081+
through yet, so it folds a caught panic into the same `OdbcError::Panic` a
3082+
non-panicking phase-one failure (`HY007`) already produces, and phase two's
3083+
ordinary `panic_safe` posts it to the target's queue as `HY000` — exactly
3084+
where the spec says this call's diagnostics belong. No SQLSTATE this
3085+
function returns changed; the previously-unguarded path now returns one
3086+
(`HY000`) instead of aborting.
3087+
30693088
[Unreleased]: https://github.com/stackabletech/stackable-odbc-core/commits/HEAD

src/ffi/desc.rs

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,8 +1189,14 @@ pub unsafe fn sql_set_desc_rec<B: Backend>(
11891189
/// - `01000` General warning — not produced here.
11901190
/// - `08S01` Communication link failure — not returned; this call performs no
11911191
/// I/O.
1192-
/// - `HY000` General error — returned for a failure with no more specific code,
1193-
/// including an internal panic caught by `panic_safe`.
1192+
/// - `HY000` General error — returned for a failure with no more specific
1193+
/// code, including an internal panic. Phase two's own panic is caught by
1194+
/// `panic_safe`, as everywhere else; phase one's is caught by
1195+
/// `panic::catch_panic_as_error` (`src/panic.rs`, crate-private), since
1196+
/// phase one holds no target handle to post a diagnostic through yet —
1197+
/// both are folded into the same
1198+
/// `OdbcError::Panic` and posted to the target's queue by phase two's
1199+
/// `panic_safe`, exactly where `HY007` is.
11941200
/// - `HY001` Memory allocation error — not returned; allocation here is an
11951201
/// infallible `Box`/`HashMap` clone.
11961202
/// - `HY007` Associated statement is not prepared — returned when the source is
@@ -1244,7 +1250,15 @@ pub unsafe fn sql_copy_desc<B: Backend>(
12441250
// guard — so that the lock is released before phase two is a fact its
12451251
// signature states rather than something this function has to remember.
12461252
let Some(snapshot) = HandleScope::with_group(source_desc_handle, HandleKind::Desc, |scope| {
1247-
scope.snapshot_descriptor::<B>(source_desc_handle)
1253+
// `with_group` is a plain lock-then-call with no `catch_unwind` of its
1254+
// own, so a panic from `describe_col` (via `snapshot_ird`) would
1255+
// otherwise unwind straight through it and across the `extern
1256+
// "system"` boundary. Caught here and folded into the same `Err`
1257+
// shape a non-panicking failure already returns, so it flows through
1258+
// the `snapshot?` below and phase two's `panic_safe` posts it to the
1259+
// target's queue exactly where every other diagnostic this call
1260+
// makes belongs.
1261+
crate::panic::catch_panic_as_error(|| scope.snapshot_descriptor::<B>(source_desc_handle))
12481262
}) else {
12491263
tracing::debug!("SQLCopyDesc -> INVALID_HANDLE (source)");
12501264
return SqlReturn::INVALID_HANDLE;
@@ -1331,8 +1345,9 @@ mod tests {
13311345
use crate::ffi::diag::sql_get_diag_rec_w;
13321346
use crate::ffi::stmt_attr::sql_get_stmt_attr_w;
13331347
use crate::test_utils::{
1334-
MockAltBackend, MockBackend, MockLongDataBackend, MockRecordingBackend,
1335-
MockTypeInfoBackend, alloc_env_conn_stmt, cleanup_env_conn_stmt, with_descriptor,
1348+
MockAltBackend, MockBackend, MockLongDataBackend, MockPanickingDescribeBackend,
1349+
MockRecordingBackend, MockTypeInfoBackend, alloc_env_conn_stmt, cleanup_env_conn_stmt,
1350+
with_descriptor,
13361351
};
13371352
use crate::types::sql_state;
13381353
use odbc_sys::{CDataType, HandleType, ParamType, SqlDataType, StatementAttribute};
@@ -3180,6 +3195,87 @@ mod tests {
31803195
}
31813196
}
31823197

3198+
/// Audit finding B4: phase one runs `describe_col` (through
3199+
/// `snapshot_ird`) before phase two's `panic_safe` is ever reached, so a
3200+
/// panicking `describe_col` used to unwind straight out of
3201+
/// `sql_copy_desc` and across the `extern "system"` boundary
3202+
/// `forward_ffi!` generates for it — an abort, not a `SqlReturn`. Driven
3203+
/// through `catch_unwind` because that escape is exactly what a bare
3204+
/// `assert_eq!` on the return value cannot see: before the fix, this test
3205+
/// itself never gets to the `assert_eq!`, because the panic unwinds
3206+
/// through it too.
3207+
#[test]
3208+
fn copy_desc_from_ird_with_panicking_describe_col_returns_error_not_abort() {
3209+
unsafe {
3210+
let (env, conn, stmt) =
3211+
crate::test_utils::alloc_connected_env_conn_stmt::<MockPanickingDescribeBackend>();
3212+
3213+
let sql: Vec<u16> = "SELECT 1".encode_utf16().collect();
3214+
assert_eq!(
3215+
crate::ffi::execute::sql_exec_direct_w::<MockPanickingDescribeBackend>(
3216+
stmt,
3217+
sql.as_ptr(),
3218+
i32::try_from(sql.len()).expect("short"),
3219+
),
3220+
SqlReturn::SUCCESS,
3221+
"precondition: the statement ran and the IRD has a column to describe"
3222+
);
3223+
3224+
let ird =
3225+
desc_token_of::<MockPanickingDescribeBackend>(stmt, StatementAttribute::ImpRowDesc);
3226+
let mut target: *mut c_void = std::ptr::null_mut();
3227+
assert_eq!(
3228+
crate::ffi::handle::sql_alloc_handle::<MockPanickingDescribeBackend>(
3229+
HandleType::Desc as i16,
3230+
conn,
3231+
&mut target,
3232+
),
3233+
SqlReturn::SUCCESS,
3234+
"precondition: an explicit target descriptor"
3235+
);
3236+
3237+
let result = std::panic::catch_unwind(|| {
3238+
sql_copy_desc::<MockPanickingDescribeBackend>(ird, target)
3239+
});
3240+
assert!(
3241+
result.is_ok(),
3242+
"a panic in phase one must be caught and reported as SQL_ERROR, \
3243+
not unwind across the extern \"system\" boundary"
3244+
);
3245+
assert_eq!(result.unwrap(), SqlReturn::ERROR);
3246+
assert_eq!(
3247+
first_sqlstate_of::<MockPanickingDescribeBackend>(target),
3248+
sql_state::GENERAL_ERROR,
3249+
"the panic is routed through phase two's panic_safe onto the \
3250+
target's queue, exactly where SQLCopyDesc's other diagnostics \
3251+
(e.g. HY007) are posted"
3252+
);
3253+
3254+
// The group lock was never held across the panic (it is caught
3255+
// inside phase one's own closure, before `with_group` even sees
3256+
// it), so it is not poisoned and the statement is still usable —
3257+
// prove it with an ordinary follow-up call.
3258+
let mut columns: i16 = -1;
3259+
assert_eq!(
3260+
crate::ffi::cursor::sql_num_result_cols::<MockPanickingDescribeBackend>(
3261+
stmt,
3262+
&mut columns,
3263+
),
3264+
SqlReturn::SUCCESS,
3265+
"the statement's group lock must still be usable after the panic"
3266+
);
3267+
assert_eq!(columns, 1);
3268+
3269+
let _ = crate::ffi::handle::sql_free_handle::<MockPanickingDescribeBackend>(
3270+
HandleType::Desc as i16,
3271+
target,
3272+
);
3273+
crate::test_utils::cleanup_connected_env_conn_stmt::<MockPanickingDescribeBackend>(
3274+
env, conn, stmt,
3275+
);
3276+
}
3277+
}
3278+
31833279
/// The APD's token, as the application receives it.
31843280
unsafe fn apd_of(stmt: *mut c_void) -> *mut c_void {
31853281
let mut token: *mut c_void = std::ptr::null_mut();

src/panic.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,43 @@ where
109109
}
110110
}
111111

112+
/// Catch a panic escaping `f`, converting it into the same
113+
/// [`OdbcError::Panic`] shape a non-panicking failure already returns.
114+
///
115+
/// `SQLCopyDesc`'s phase one (`ffi::desc::sql_copy_desc`) calls this from
116+
/// inside `HandleScope::with_group` on the *source* descriptor's group,
117+
/// before phase two's [`panic_safe`] ever runs on the *target*. `with_group`
118+
/// carries no `catch_unwind` of its own — it is a plain lock-then-call, not an
119+
/// FFI-boundary guard — so a panic reaching `describe_col` through
120+
/// `snapshot_ird` (driver-author code, the same surface every other
121+
/// `Backend` call runs under a guard for) would otherwise unwind straight
122+
/// through it, past `sql_copy_desc`, and across the `extern "system"`
123+
/// boundary `forward_ffi!` generates for it.
124+
///
125+
/// Unlike [`panic_safe_unlocked`], this is not itself an FFI-boundary guard:
126+
/// it exists so phase one's panic can be turned into the *same* `Err` shape
127+
/// phase one already returns for a non-panicking failure (`HY007`, an
128+
/// unpopulated IRD), rather than becoming a second, cruder failure mode. Both
129+
/// flow through phase two's `panic_safe` unchanged, via the `?` on the
130+
/// snapshot [`sql_copy_desc`] already had — so the panic is posted as `HY000`
131+
/// to the *target*'s diagnostic queue, exactly where the spec says this
132+
/// call's diagnostics belong (and where `HY007` was already posted). There is
133+
/// no handle to push a diagnostic through at the point this function runs —
134+
/// the same reason [`panic_safe_unlocked`] posts none on its own two call
135+
/// sites — so it returns a plain `Result` for its caller to route onward
136+
/// instead of trying.
137+
///
138+
/// [`panic_safe`]: crate::panic::panic_safe
139+
pub(crate) fn catch_panic_as_error<T>(
140+
f: impl FnOnce() -> Result<T, OdbcError>,
141+
) -> Result<T, OdbcError> {
142+
std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_panic| {
143+
Err(OdbcError::Panic {
144+
message: "internal driver panic".into(),
145+
})
146+
})
147+
}
148+
112149
#[cfg(test)]
113150
mod tests {
114151
use super::*;

src/test_utils.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4014,6 +4014,135 @@ impl Backend for MockFailingDescribeBackend {
40144014
minimal_capability_decls!();
40154015
}
40164016

4017+
// ---------------------------------------------------------------------------
4018+
// A backend whose describe_col panics, for SQLCopyDesc phase-one panic safety
4019+
// ---------------------------------------------------------------------------
4020+
4021+
/// A statement whose `describe_col` panics instead of returning an error.
4022+
///
4023+
/// `column_count` is 1 — enough to make `snapshot_ird`'s loop
4024+
/// (`src/handles/scope.rs`) call `describe_col` at all, which is the one
4025+
/// driver-author call `SQLCopyDesc`'s phase one runs before phase two's
4026+
/// `panic_safe` is even reached.
4027+
pub struct MockPanickingDescribeStatement;
4028+
4029+
impl StatementBackend for MockPanickingDescribeStatement {
4030+
type Error = OdbcError;
4031+
4032+
fn column_count(&self) -> i16 {
4033+
1
4034+
}
4035+
4036+
fn describe_col(
4037+
&self,
4038+
_col: u16,
4039+
) -> Result<Cow<'_, crate::types::ColumnDescriptor>, OdbcError> {
4040+
panic!("mock describe_col panic");
4041+
}
4042+
}
4043+
4044+
/// Hands out statements whose column metadata cannot be described without
4045+
/// panicking.
4046+
///
4047+
/// The `MockFailingDescribeBackend` sibling above proves core surfaces a
4048+
/// backend's own *error*; this one proves core survives a backend's *panic*
4049+
/// — the distinction `SQLCopyDesc`'s phase one needed a guard for.
4050+
pub struct MockPanickingDescribeBackend;
4051+
4052+
impl Backend for MockPanickingDescribeBackend {
4053+
type Connection = MockConnection;
4054+
type Statement = MockPanickingDescribeStatement;
4055+
type Error = OdbcError;
4056+
type CancelToken = MockCancelToken;
4057+
4058+
fn connect(_: &ConnectParams) -> Result<MockConnection, OdbcError> {
4059+
Ok(MockConnection)
4060+
}
4061+
fn disconnect(_: &mut MockConnection) -> Result<(), OdbcError> {
4062+
Ok(())
4063+
}
4064+
fn cancel_token(_conn: &Self::Connection) -> Self::CancelToken {
4065+
MockCancelToken::default()
4066+
}
4067+
fn cancel(token: &Self::CancelToken) -> Result<(), Self::Error> {
4068+
token
4069+
.cancelled
4070+
.store(true, std::sync::atomic::Ordering::SeqCst);
4071+
Ok(())
4072+
}
4073+
fn is_cancelled(token: &Self::CancelToken) -> bool {
4074+
token.cancelled.load(std::sync::atomic::Ordering::SeqCst)
4075+
}
4076+
fn exec_direct(
4077+
_: &MockConnection,
4078+
_: &Self::CancelToken,
4079+
_: &str,
4080+
) -> Result<MockPanickingDescribeStatement, OdbcError> {
4081+
Ok(MockPanickingDescribeStatement)
4082+
}
4083+
fn prepare(
4084+
_: &MockConnection,
4085+
_: &Self::CancelToken,
4086+
_: &str,
4087+
) -> Result<MockPanickingDescribeStatement, OdbcError> {
4088+
Ok(MockPanickingDescribeStatement)
4089+
}
4090+
fn execute(
4091+
_: &MockConnection,
4092+
_: &Self::CancelToken,
4093+
_: &mut MockPanickingDescribeStatement,
4094+
_: &[crate::types::ColumnValue],
4095+
) -> Result<crate::types::ExecuteOutcome, OdbcError> {
4096+
Ok(crate::types::ExecuteOutcome::default())
4097+
}
4098+
fn get_info(_: &MockConnection, _: crate::types::InfoType) -> Result<InfoValue, OdbcError> {
4099+
Err(OdbcError::NotImplemented {
4100+
feature: "get_info".into(),
4101+
})
4102+
}
4103+
fn get_functions() -> Cow<'static, [crate::function_id::FunctionId]> {
4104+
Cow::Borrowed(&[])
4105+
}
4106+
fn get_type_info(_conn: &Self::Connection) -> Cow<'static, [TypeInfoRow]> {
4107+
Cow::Borrowed(&[])
4108+
}
4109+
fn tables(
4110+
_: &MockConnection,
4111+
_: &Self::CancelToken,
4112+
_: &crate::types::TablesQuery<'_>,
4113+
) -> Result<Vec<TableRow>, OdbcError> {
4114+
Ok(Vec::new())
4115+
}
4116+
fn columns(
4117+
_: &MockConnection,
4118+
_: &Self::CancelToken,
4119+
_: &crate::types::ColumnsQuery<'_>,
4120+
) -> Result<Vec<ColumnRow>, OdbcError> {
4121+
Ok(Vec::new())
4122+
}
4123+
4124+
fn supports_catalogs(_conn: &Self::Connection) -> bool {
4125+
false
4126+
}
4127+
fn supports_schemas(_conn: &Self::Connection) -> bool {
4128+
false
4129+
}
4130+
fn alter_table_support(_conn: &Self::Connection) -> u32 {
4131+
0
4132+
}
4133+
fn outer_join_capabilities(_conn: &Self::Connection) -> u32 {
4134+
0
4135+
}
4136+
fn default_txn_isolation(_conn: &Self::Connection) -> u32 {
4137+
0
4138+
}
4139+
fn txn_isolation_options(_conn: &Self::Connection) -> u32 {
4140+
0
4141+
}
4142+
4143+
minimal_capability_decls!();
4144+
}
4145+
40174146
// ---------------------------------------------------------------------------
40184147
// A backend that rejects an unknown catalog, for 3D000
40194148
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)