Skip to main content

oracledb_protocol/
lib.rs

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