Skip to main content

oracledb_protocol/
lib.rs

1#![forbid(unsafe_code)]
2
3pub mod capabilities;
4pub mod crypto;
5pub mod dpl;
6pub mod net;
7pub mod oson;
8pub mod packet;
9pub mod sql;
10pub mod thin;
11pub mod tls;
12pub mod vector;
13pub mod wire;
14
15use std::borrow::Cow;
16
17pub const PYTHON_ORACLEDB_REFERENCE_TAG: &str = "v4.0.1";
18pub const PYTHON_ORACLEDB_REFERENCE_COMMIT: &str = "3daef052904e41668bb862e6fa40f43c22a81beb";
19pub const TNS_VERSION_MIN: u16 = 300;
20pub const TNS_VERSION_DESIRED: u16 = 319;
21
22/// Structured details for a protocol resource-limit violation.
23///
24/// The limit names are stable policy keys from [`wire::ProtocolLimits`], so
25/// callers can classify or log the exact bound that rejected a payload without
26/// parsing the display string.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub struct ResourceLimit {
29    pub limit: &'static str,
30    pub observed: usize,
31    pub maximum: usize,
32}
33
34#[derive(Debug, thiserror::Error)]
35#[non_exhaustive]
36pub enum ProtocolError {
37    #[error("truncated packet header: got {got} bytes")]
38    TruncatedHeader { got: usize },
39    #[error("invalid packet length {length}; expected at least {minimum}")]
40    InvalidPacketLength { length: usize, minimum: usize },
41    #[error("packet length {declared} exceeds available bytes {available}")]
42    IncompletePacket { declared: usize, available: usize },
43    #[error("packet length {length} exceeds TNS two-byte length field")]
44    PacketTooLarge { length: usize },
45    #[error("unsupported TNS version {version}")]
46    UnsupportedVersion { version: u16 },
47    #[error("invalid client identity field {field}: {reason}")]
48    InvalidClientIdentity {
49        field: &'static str,
50        reason: Cow<'static, str>,
51    },
52    #[error("invalid connect descriptor: {0}")]
53    InvalidConnectDescriptor(String),
54    #[error("TTC decode failed: {0}")]
55    TtcDecode(&'static str),
56    #[error("unknown TTC message type {message_type} at position {position}")]
57    UnknownMessageType { message_type: u8, position: usize },
58    #[error("protocol resource limit exceeded: {limit} observed {observed}, maximum {maximum}")]
59    ResourceLimit {
60        limit: &'static str,
61        observed: usize,
62        maximum: usize,
63    },
64    #[error("server returned Oracle error: {0}")]
65    ServerError(String),
66    #[error("server returned Oracle error: {message}")]
67    ServerErrorWithRowCount { message: String, row_count: u64 },
68    #[error("server returned Oracle error: {}", .0.message)]
69    ServerErrorInfo(Box<ServerErrorDetails>),
70    #[error("unsupported feature: {0}")]
71    UnsupportedFeature(&'static str),
72    #[error("missing authentication parameter {key}")]
73    MissingAuthParameter { key: &'static str },
74    #[error("unsupported password verifier type {verifier_type:#x}")]
75    UnsupportedVerifier { verifier_type: u32 },
76    #[error("invalid AES key length")]
77    InvalidAesKey,
78    #[error("invalid server authentication response")]
79    InvalidServerResponse,
80    // The next three mirror python-oracledb error numbers DPY-8000, DPY-8001
81    // and DPY-4041 so a Python-facing layer can map them one-to-one.
82    // "exeeds" reproduces the reference's spelling (errors.py ERR_VALUE_TOO_LARGE).
83    #[error(
84        "DPY-8000: value of size {actual_size} exeeds maximum allowed size of \
85         {max_size} for column \"{column_name}\" of row {row_num}"
86    )]
87    ValueTooLarge {
88        actual_size: usize,
89        max_size: u32,
90        column_name: String,
91        row_num: u64,
92    },
93    #[error("DPY-8001: value for column \"{column_name}\" may not be null on row {row_num}")]
94    NullsNotAllowed { column_name: String, row_num: u64 },
95    #[error("DPY-4041: the maximum size of a Direct Path load has been exceeded")]
96    DirectPathLoadTooMuchData,
97    #[error("not implemented: {0}")]
98    NotImplemented(&'static str),
99    // OSON / DB_TYPE_JSON. These mirror python-oracledb error numbers so the
100    // Python-facing layer can map them one-to-one:
101    //   DPY-5004 ERR_OSON_NODE_TYPE_NOT_SUPPORTED is *not* this; 5004 is the
102    //   "not previously encoded" case (bad magic/version) and 5006 is a
103    //   structurally invalid OSON image (truncation / bad offset).
104    #[error("DPY-5004: input data is not in the OSON format: {0}")]
105    OsonNotEncoded(&'static str),
106    #[error("DPY-5006: invalid OSON data: {0}")]
107    OsonInvalid(&'static str),
108    /// A JSON scalar node decoded to an Oracle type with no Python mapping
109    /// (e.g. INTERVAL YEAR TO MONTH). Mirrors DPY-3007 / ERR_DB_TYPE_NOT_SUPPORTED.
110    #[error("DPY-3007: the data type {0} is not supported")]
111    OsonTypeNotSupported(&'static str),
112}
113
114impl ProtocolError {
115    pub fn resource_limit(&self) -> Option<ResourceLimit> {
116        match self {
117            Self::ResourceLimit {
118                limit,
119                observed,
120                maximum,
121            } => Some(ResourceLimit {
122                limit,
123                observed: *observed,
124                maximum: *maximum,
125            }),
126            _ => None,
127        }
128    }
129}
130
131pub type Result<T> = std::result::Result<T, ProtocolError>;
132
133/// Structured server error information parsed from the TTC error trailer
134/// (reference impl/thin/messages/base.pyx `_process_error_info`).
135#[derive(Clone, Debug, Default, Eq, PartialEq)]
136pub struct ServerErrorDetails {
137    pub message: String,
138    /// ORA error number (extended field).
139    pub code: u32,
140    /// Error position / parse offset (sb2; 0 when not reported).
141    pub pos: i32,
142    /// Server-reported row count at the time of the error.
143    pub row_count: u64,
144    /// Encoded rowid of the last affected row, if any.
145    pub rowid: Option<String>,
146    /// Row counts received before the error when
147    /// `executemany(arraydmlrowcounts=True)` was requested.
148    pub array_dml_row_counts: Option<Vec<u64>>,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
152pub struct ClientIdentity {
153    pub program: String,
154    pub machine: String,
155    pub osuser: String,
156    pub terminal: String,
157    pub driver_name: String,
158}
159
160impl ClientIdentity {
161    pub fn new(
162        program: impl Into<String>,
163        machine: impl Into<String>,
164        osuser: impl Into<String>,
165        terminal: impl Into<String>,
166        driver_name: impl Into<String>,
167    ) -> Result<Self> {
168        Ok(Self {
169            program: sanitize_identity_field("program", program.into())?,
170            machine: sanitize_identity_field("machine", machine.into())?,
171            osuser: sanitize_identity_field("osuser", osuser.into())?,
172            terminal: sanitize_identity_field("terminal", terminal.into())?,
173            driver_name: sanitize_identity_field("driver_name", driver_name.into())?,
174        })
175    }
176}
177
178fn sanitize_identity_field(field: &'static str, value: String) -> Result<String> {
179    let trimmed = value.trim();
180    if trimmed.is_empty() {
181        return Err(ProtocolError::InvalidClientIdentity {
182            field,
183            reason: Cow::Borrowed("value must not be empty"),
184        });
185    }
186
187    let mut out = String::with_capacity(trimmed.len().min(30));
188    for ch in trimmed.chars() {
189        if ch.is_control() {
190            return Err(ProtocolError::InvalidClientIdentity {
191                field,
192                reason: Cow::Borrowed("control characters are not allowed"),
193            });
194        }
195        if out.len() + ch.len_utf8() > 30 {
196            break;
197        }
198        out.push(ch);
199    }
200    Ok(out)
201}
202
203/// Fuzz-only thin wrappers over `pub(crate)` decoder entry points.
204///
205/// This module is compiled **only** under `--cfg fuzzing` (set automatically
206/// by `cargo-fuzz`). It exposes the crate-internal decode functions that take
207/// adversarial server bytes — the server-error trailer parser and the
208/// `pub(crate)` scalar codecs — so the `fuzz/` targets can call them directly
209/// without widening the normal public API. Each wrapper is a zero-logic
210/// forward to the real function; the goal is to prove these never panic on
211/// malformed input (they must fail closed with a [`ProtocolError`]).
212#[cfg(fuzzing)]
213pub mod fuzz_api {
214    use crate::wire::{BoundedReader, TtcReader};
215    use crate::Result;
216
217    /// Fuzz the server-error trailer parser (`parse_server_error_info`).
218    /// `ttc_field_version` is taken from the first input byte so the fuzzer
219    /// can explore both the legacy and 20.1+ trailer layouts.
220    pub fn fuzz_parse_server_error_info(data: &[u8]) -> Result<()> {
221        let (ttc_field_version, rest) = data.split_first().map_or((24u8, data), |(v, r)| (*v, r));
222        let mut reader = TtcReader::new(rest);
223        crate::thin::parse_server_error_info(&mut reader, ttc_field_version).map(|_| ())
224    }
225
226    /// Fuzz the server-side piggyback skipper (`skip_server_side_piggyback`).
227    pub fn fuzz_skip_server_side_piggyback(data: &[u8]) -> Result<()> {
228        let mut reader = TtcReader::new(data);
229        crate::thin::skip_server_side_piggyback(&mut reader).map(|_| ())
230    }
231
232    /// Fuzz every `pub(crate)` scalar codec that decodes raw column bytes.
233    /// Drives them all from one input so a single target covers the full
234    /// scalar surface (NUMBER, datetime, intervals, binary float/double).
235    pub fn fuzz_scalar_codecs(data: &[u8]) {
236        let _ = crate::thin::decode_number_value(data);
237        let _ = crate::thin::decode_datetime_value(data);
238        let _ = crate::thin::decode_interval_ds(data);
239        let _ = crate::thin::decode_interval_ym(data);
240        let _ = crate::thin::decode_binary_float(data);
241        let _ = crate::thin::decode_binary_double(data);
242    }
243
244    /// Fuzz the DbObject packed-image reader by walking arbitrary image bytes
245    /// through the same length/header/value readers used by ADT and collection
246    /// decoding. The selector bytes choose a bounded sequence of operations;
247    /// expected decode failures are ignored, but panics/OOMs are bugs.
248    pub fn fuzz_dbobject_image_walk(data: &[u8]) {
249        let (ops, payload) = data.split_at(data.len().min(64));
250        let mut reader = crate::thin::DbObjectPackedReader::new(payload);
251        for op in ops {
252            match op % 7 {
253                0 => {
254                    let _ = reader.read_u8();
255                }
256                1 => {
257                    let _ = reader.read_i32be();
258                }
259                2 => {
260                    let _ = reader.read_length();
261                }
262                3 => {
263                    let _ = reader.read_value_bytes();
264                }
265                4 => {
266                    let _ = reader.read_header();
267                }
268                5 => {
269                    let _ = reader.read_atomic_null(op & 0x80 != 0);
270                }
271                _ => {
272                    let count = usize::from(*op);
273                    let _ = reader.alloc_count_checked(count, 1);
274                    let _: Vec<u8> = reader.with_capacity_bounded(count, 1);
275                }
276            }
277            if reader.remaining() == 0 {
278                break;
279            }
280        }
281    }
282
283    /// Fuzz DbObject scalar/image-adjacent decoders that are not all reachable
284    /// through one public parser boundary. This includes text, XMLTYPE, BFILE
285    /// locator names, LOB text decoding, binary float/double, and the
286    /// crate-private BINARY_INTEGER text parser.
287    pub fn fuzz_dbobject_scalars(data: &[u8]) {
288        let (selector, payload) = data.split_first().map_or((0u8, data), |(v, r)| (*v, r));
289        let dbtype_name = match selector & 0x03 {
290            0 => "DB_TYPE_VARCHAR",
291            1 => "DB_TYPE_NVARCHAR",
292            2 => "DB_TYPE_CHAR",
293            _ => "DB_TYPE_NCHAR",
294        };
295        let csfrm = if selector & 0x04 == 0 {
296            crate::thin::CS_FORM_IMPLICIT
297        } else {
298            crate::thin::CS_FORM_NCHAR
299        };
300        let locator = (selector & 0x08 != 0).then_some(payload);
301
302        let _ = crate::thin::decode_dbobject_text(payload, dbtype_name);
303        let _ = crate::thin::decode_dbobject_xmltype_text(payload);
304        let _ = crate::thin::decode_lob_text(payload, csfrm, locator);
305        let _ = crate::thin::decode_bfile_locator_name(payload);
306        let _ = crate::thin::decode_dbobject_binary_float(payload);
307        let _ = crate::thin::decode_dbobject_binary_double(payload);
308        if let Ok(text) = core::str::from_utf8(payload) {
309            let _ = crate::thin::parse_binary_integer_u32(text);
310        }
311    }
312
313    /// Fuzz the Advanced Queuing response decoders (enqueue / dequeue / array).
314    /// The first input byte selects the negotiated TTC field version and the
315    /// payload kind so the fuzzer can reach the RAW / JSON / Object branches;
316    /// the rest is the adversarial server payload. All three AQ parsers must
317    /// fail closed on any malformed input (they only `read_*` from a bounded
318    /// `TtcReader`, never index raw bytes).
319    pub fn fuzz_aq_responses(data: &[u8]) {
320        use crate::thin::aq::{
321            parse_aq_array_response, parse_aq_deq_response, parse_aq_enq_response, AqPayloadKind,
322        };
323        let (selector, payload) = data.split_first().map_or((0u8, data), |(v, r)| (*v, r));
324        let caps = crate::thin::ClientCapabilities {
325            ttc_field_version: 24 - (selector & 0x07),
326            ..crate::thin::ClientCapabilities::default()
327        };
328        let kind = match (selector >> 3) % 3 {
329            0 => AqPayloadKind::Raw,
330            1 => AqPayloadKind::Json,
331            _ => AqPayloadKind::Object,
332        };
333        let _ = parse_aq_enq_response(payload, caps);
334        let _ = parse_aq_deq_response(payload, caps, &kind);
335        // `operation` and `props_count` are derived from the selector so the
336        // array decoder explores both the dequeue-array and enqueue-array shapes.
337        let operation = i32::from(selector >> 6);
338        let props_count = u32::from(selector & 0x0f);
339        let _ = parse_aq_array_response(payload, caps, operation, props_count, &kind);
340    }
341
342    /// Fuzz the subscription (CQN/AQ-notification) response + notification
343    /// stream decoders. The first input byte drives the TTC field version, the
344    /// namespace, and the QoS flags so the fuzzer reaches the OAC-record and
345    /// grouping-notification branches. Both parsers must fail closed.
346    pub fn fuzz_subscr_responses(data: &[u8]) {
347        use crate::thin::{
348            parse_notification_stream, parse_subscribe_response, ClientCapabilities,
349        };
350        let (selector, payload) = data.split_first().map_or((0u8, data), |(v, r)| (*v, r));
351        let caps = ClientCapabilities {
352            ttc_field_version: 24 - (selector & 0x07),
353            ..ClientCapabilities::default()
354        };
355        let _ = parse_subscribe_response(payload, caps);
356        let namespace = u32::from(selector >> 4);
357        let public_qos = u32::from((selector >> 2) & 0x03);
358        let _ = parse_notification_stream(payload, namespace, public_qos, None);
359        let _ = parse_notification_stream(payload, namespace, public_qos, Some("FUZZDB"));
360    }
361
362    /// Fuzz the connect-string parsers on one untrusted string: the TNS
363    /// connect-descriptor / EZConnect-Plus parser
364    /// ([`crate::net::connectstring::parse`]) and the in-memory tnsnames.ora
365    /// lexer (`tnsnames::fuzz_parse_file`).
366    ///
367    /// Both consume untrusted env / config / user input and must *never*
368    /// panic / OOM / overflow the stack — only return `Err` (or, for the
369    /// descriptor case, `Ok(None)` meaning "this is a tnsnames alias"). The
370    /// descriptor recursion-depth DoS was fixed in bead `uf8`
371    /// (`MAX_DESCRIPTOR_DEPTH`); this entry point guards that fix and hunts
372    /// siblings in the EZConnect quote/host/port lexer and the tnsnames
373    /// comment / multi-line / paren-balancing tokenizer.
374    pub fn fuzz_connect_string(input: &str) {
375        let _ = crate::net::connectstring::parse(input);
376        let _ = crate::net::connectstring::tnsnames::fuzz_parse_file(input);
377    }
378
379    /// Drive `sql::parse_alter_session_value` — the `ALTER SESSION SET <key> =
380    /// <value>` value extractor used to track session state (current_schema /
381    /// edition) the server reflects back. It must never panic on arbitrary
382    /// statement text, including non-UTF-8-boundary keys/values. The first byte
383    /// selects the lookup key so the fuzzer exercises the matched + unmatched
384    /// branches.
385    pub fn fuzz_alter_session_value(input: &str) {
386        let keys = ["current_schema", "edition", "time_zone", ""];
387        let key = keys[input.as_bytes().first().copied().unwrap_or(0) as usize % keys.len()];
388        let _ = crate::sql::parse_alter_session_value(input, key);
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn identity_fields_are_trimmed_and_bounded() {
398        let identity = ClientIdentity::new(
399            "  program-name-longer-than-thirty-bytes  ",
400            "machine",
401            "user",
402            "terminal",
403            "driver",
404        )
405        .expect("valid identity fields should sanitize");
406
407        assert_eq!(identity.program, "program-name-longer-than-thirt");
408        assert_eq!(identity.machine, "machine");
409    }
410
411    #[test]
412    fn identity_rejects_empty_fields() {
413        let err = ClientIdentity::new("", "machine", "user", "terminal", "driver")
414            .expect_err("empty program should be rejected");
415        assert!(matches!(
416            err,
417            ProtocolError::InvalidClientIdentity {
418                field: "program",
419                ..
420            }
421        ));
422    }
423
424    #[test]
425    fn resource_limit_accessor_returns_typed_details() {
426        let err = ProtocolError::ResourceLimit {
427            limit: "response_bytes",
428            observed: 33,
429            maximum: 32,
430        };
431        assert_eq!(
432            err.resource_limit(),
433            Some(ResourceLimit {
434                limit: "response_bytes",
435                observed: 33,
436                maximum: 32,
437            })
438        );
439        assert_eq!(ProtocolError::TtcDecode("bad").resource_limit(), None);
440    }
441}