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