Skip to content

Commit 9a12356

Browse files
dainnilssonclaude
andcommitted
Raise CtapError and ClientError directly from Rust instead of encoding as ValueError
Rust PyO3 bindings now import and instantiate the proper Python exception types (CtapError, ClientError, PinRequiredError) directly, eliminating ~50 instances of try/except ValueError + _handle_native_error() boilerplate across 7 Python files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f986a6a commit 9a12356

9 files changed

Lines changed: 199 additions & 346 deletions

File tree

crates/fido2-pyo3/src/py_client.rs

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -137,23 +137,65 @@ impl ClientDataCollector {
137137

138138
// ---- Error conversion helpers ----
139139

140+
fn make_client_error(py: Python<'_>, code: u32, cause: Option<PyObject>) -> PyErr {
141+
let Ok(m) = py.import("fido2.client") else {
142+
return PyValueError::new_err(format!("ClientError code {code}"));
143+
};
144+
let Ok(cls) = m.getattr("ClientError") else {
145+
return PyValueError::new_err(format!("ClientError code {code}"));
146+
};
147+
let args = match cause {
148+
Some(c) => (code, c),
149+
None => (code, py.None()),
150+
};
151+
match cls.call1(args) {
152+
Ok(inst) => PyErr::from_value(inst.into_any()),
153+
Err(e) => e,
154+
}
155+
}
156+
140157
fn client_err(e: client::ClientError) -> PyErr {
141-
match e {
142-
client::ClientError::BadRequest(msg) => {
143-
PyValueError::new_err(format!("CLIENT_BAD_REQUEST:{}", msg))
144-
}
145-
client::ClientError::ConfigurationUnsupported(msg) => {
146-
PyValueError::new_err(format!("CLIENT_CONFIG_UNSUPPORTED:{}", msg))
147-
}
148-
client::ClientError::PinRequired => {
149-
PyValueError::new_err("CLIENT_PIN_REQUIRED".to_string())
150-
}
151-
client::ClientError::DeviceIneligible => {
152-
PyValueError::new_err("CLIENT_DEVICE_INELIGIBLE".to_string())
158+
Python::with_gil(|py| match e {
159+
client::ClientError::BadRequest(msg) => make_client_error(
160+
py,
161+
2,
162+
Some(msg.into_pyobject(py).unwrap().into_any().unbind()),
163+
),
164+
client::ClientError::ConfigurationUnsupported(msg) => make_client_error(
165+
py,
166+
3,
167+
Some(msg.into_pyobject(py).unwrap().into_any().unbind()),
168+
),
169+
client::ClientError::PinRequired => match py.import("fido2.client") {
170+
Ok(m) => match m.getattr("PinRequiredError") {
171+
Ok(cls) => match cls.call0() {
172+
Ok(inst) => PyErr::from_value(inst.into_any()),
173+
Err(e) => e,
174+
},
175+
Err(e) => e,
176+
},
177+
Err(e) => e,
178+
},
179+
client::ClientError::DeviceIneligible => make_client_error(py, 4, None),
180+
client::ClientError::Timeout => make_client_error(py, 5, None),
181+
client::ClientError::Ctap(e) => {
182+
// Convert CTAP error to ClientError via _ctap2client_err
183+
let ctap_err = crate::py_ctap::ctap_err(e);
184+
match py.import("fido2.client") {
185+
Ok(m) => match m.getattr("_ctap2client_err") {
186+
Ok(func) => {
187+
let ctap_exc = ctap_err.value(py);
188+
match func.call1((ctap_exc,)) {
189+
Ok(inst) => PyErr::from_value(inst.into_any()),
190+
Err(e) => e,
191+
}
192+
}
193+
Err(_) => ctap_err,
194+
},
195+
Err(_) => ctap_err,
196+
}
153197
}
154-
client::ClientError::Timeout => PyValueError::new_err("CLIENT_TIMEOUT".to_string()),
155-
client::ClientError::Ctap(e) => crate::py_ctap::ctap_err(e),
156-
}
198+
})
157199
}
158200

159201
// ---- UserInteraction bridge ----

crates/fido2-pyo3/src/py_ctap.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,16 @@ impl CtapDevice for PyCtapDevice {
128128

129129
pub fn ctap_err(e: CtapError) -> PyErr {
130130
match e {
131-
CtapError::StatusError(status) => {
132-
PyValueError::new_err(format!("CTAP_ERR:{}", status.as_byte()))
133-
}
131+
CtapError::StatusError(status) => Python::with_gil(|py| match py.import("fido2.ctap") {
132+
Ok(m) => match m.getattr("CtapError") {
133+
Ok(cls) => match cls.call1((status.as_byte(),)) {
134+
Ok(inst) => PyErr::from_value(inst.into_any()),
135+
Err(e) => e,
136+
},
137+
Err(e) => e,
138+
},
139+
Err(e) => e,
140+
}),
134141
CtapError::InvalidResponse(ref msg) if msg.starts_with("Invalid PIN:") => {
135142
PyValueError::new_err(msg.clone())
136143
}

fido2/client/__init__.py

Lines changed: 14 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import logging
3333
from enum import IntEnum, unique
3434
from threading import Event, Timer
35-
from typing import Any, Callable, Mapping, NoReturn, Sequence
35+
from typing import Any, Callable, Mapping, Sequence
3636

3737
from _fido2_native.client import ClientDataCollector as NativeClientDataCollector
3838
from _fido2_native.client import NativeFido2Client
@@ -141,25 +141,6 @@ def __init__(
141141
super().__init__(code, cause)
142142

143143

144-
def _handle_native_error(e: ValueError) -> NoReturn:
145-
"""Convert native ValueError exceptions to appropriate Python exceptions."""
146-
msg = str(e)
147-
if msg.startswith("CTAP_ERR:"):
148-
ctap_e = CtapError(int(msg.split(":")[1]))
149-
raise _ctap2client_err(ctap_e) from None
150-
if msg.startswith("CLIENT_BAD_REQUEST:"):
151-
raise ClientError.ERR.BAD_REQUEST(msg.split(":", 1)[1]) from None
152-
if msg.startswith("CLIENT_CONFIG_UNSUPPORTED:"):
153-
raise ClientError.ERR.CONFIGURATION_UNSUPPORTED(msg.split(":", 1)[1]) from None
154-
if msg == "CLIENT_PIN_REQUIRED":
155-
raise PinRequiredError() from None
156-
if msg == "CLIENT_DEVICE_INELIGIBLE":
157-
raise ClientError.ERR.DEVICE_INELIGIBLE() from None
158-
if msg == "CLIENT_TIMEOUT":
159-
raise ClientError.ERR.TIMEOUT() from None
160-
raise ClientError.ERR.OTHER_ERROR(e)
161-
162-
163144
class AssertionSelection:
164145
"""GetAssertion result holding one or more assertions.
165146
@@ -401,10 +382,7 @@ def _enterprise_rpid_list(self, value: list[str] | None) -> None:
401382
self._native.enterprise_rpid_list = value
402383

403384
def selection(self, event: Event | None = None) -> None:
404-
try:
405-
self._native.selection(event)
406-
except ValueError as e:
407-
_handle_native_error(e)
385+
self._native.selection(event)
408386

409387
def make_credential(
410388
self,
@@ -431,15 +409,12 @@ def make_credential(
431409
logger.debug(f"Register a new credential for RP ID: {rp_id}")
432410

433411
try:
434-
try:
435-
att_resp_dict, ext_outputs = self._native.do_make_credential(
436-
json.dumps(dict(options), cls=_BytesEncoder),
437-
client_data.hash,
438-
rp_id,
439-
event,
440-
)
441-
except ValueError as e:
442-
_handle_native_error(e)
412+
att_resp_dict, ext_outputs = self._native.do_make_credential(
413+
json.dumps(dict(options), cls=_BytesEncoder),
414+
client_data.hash,
415+
rp_id,
416+
event,
417+
)
443418
finally:
444419
if timer:
445420
timer.cancel()
@@ -488,15 +463,12 @@ def get_assertion(
488463
logger.debug(f"Assert a credential for RP ID: {rp_id}")
489464

490465
try:
491-
try:
492-
assertions_dicts, ext_outputs_list = self._native.do_get_assertion(
493-
json.dumps(dict(options), cls=_BytesEncoder),
494-
client_data.hash,
495-
rp_id,
496-
event,
497-
)
498-
except ValueError as e:
499-
_handle_native_error(e)
466+
assertions_dicts, ext_outputs_list = self._native.do_get_assertion(
467+
json.dumps(dict(options), cls=_BytesEncoder),
468+
client_data.hash,
469+
rp_id,
470+
event,
471+
)
500472
finally:
501473
if timer:
502474
timer.cancel()

0 commit comments

Comments
 (0)