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#[derive(Debug, thiserror::Error)]
23pub enum ProtocolError {
24    #[error("truncated packet header: got {got} bytes")]
25    TruncatedHeader { got: usize },
26    #[error("invalid packet length {length}; expected at least {minimum}")]
27    InvalidPacketLength { length: usize, minimum: usize },
28    #[error("packet length {declared} exceeds available bytes {available}")]
29    IncompletePacket { declared: usize, available: usize },
30    #[error("packet length {length} exceeds TNS two-byte length field")]
31    PacketTooLarge { length: usize },
32    #[error("unsupported TNS version {version}")]
33    UnsupportedVersion { version: u16 },
34    #[error("invalid client identity field {field}: {reason}")]
35    InvalidClientIdentity {
36        field: &'static str,
37        reason: Cow<'static, str>,
38    },
39    #[error("invalid connect descriptor: {0}")]
40    InvalidConnectDescriptor(String),
41    #[error("TTC decode failed: {0}")]
42    TtcDecode(&'static str),
43    #[error("unknown TTC message type {message_type} at position {position}")]
44    UnknownMessageType { message_type: u8, position: usize },
45    #[error("server returned Oracle error: {0}")]
46    ServerError(String),
47    #[error("server returned Oracle error: {message}")]
48    ServerErrorWithRowCount { message: String, row_count: u64 },
49    #[error("server returned Oracle error: {}", .0.message)]
50    ServerErrorInfo(Box<ServerErrorDetails>),
51    #[error("unsupported feature: {0}")]
52    UnsupportedFeature(&'static str),
53    #[error("missing authentication parameter {key}")]
54    MissingAuthParameter { key: &'static str },
55    #[error("unsupported password verifier type {verifier_type:#x}")]
56    UnsupportedVerifier { verifier_type: u32 },
57    #[error("invalid AES key length")]
58    InvalidAesKey,
59    #[error("invalid server authentication response")]
60    InvalidServerResponse,
61    // The next three mirror python-oracledb error numbers DPY-8000, DPY-8001
62    // and DPY-4041 so a Python-facing layer can map them one-to-one.
63    // "exeeds" reproduces the reference's spelling (errors.py ERR_VALUE_TOO_LARGE).
64    #[error(
65        "DPY-8000: value of size {actual_size} exeeds maximum allowed size of \
66         {max_size} for column \"{column_name}\" of row {row_num}"
67    )]
68    ValueTooLarge {
69        actual_size: usize,
70        max_size: u32,
71        column_name: String,
72        row_num: u64,
73    },
74    #[error("DPY-8001: value for column \"{column_name}\" may not be null on row {row_num}")]
75    NullsNotAllowed { column_name: String, row_num: u64 },
76    #[error("DPY-4041: the maximum size of a Direct Path load has been exceeded")]
77    DirectPathLoadTooMuchData,
78    #[error("not implemented: {0}")]
79    NotImplemented(&'static str),
80    // OSON / DB_TYPE_JSON. These mirror python-oracledb error numbers so the
81    // Python-facing layer can map them one-to-one:
82    //   DPY-5004 ERR_OSON_NODE_TYPE_NOT_SUPPORTED is *not* this; 5004 is the
83    //   "not previously encoded" case (bad magic/version) and 5006 is a
84    //   structurally invalid OSON image (truncation / bad offset).
85    #[error("DPY-5004: input data is not in the OSON format: {0}")]
86    OsonNotEncoded(&'static str),
87    #[error("DPY-5006: invalid OSON data: {0}")]
88    OsonInvalid(&'static str),
89    /// A JSON scalar node decoded to an Oracle type with no Python mapping
90    /// (e.g. INTERVAL YEAR TO MONTH). Mirrors DPY-3007 / ERR_DB_TYPE_NOT_SUPPORTED.
91    #[error("DPY-3007: the data type {0} is not supported")]
92    OsonTypeNotSupported(&'static str),
93}
94
95pub type Result<T> = std::result::Result<T, ProtocolError>;
96
97/// Structured server error information parsed from the TTC error trailer
98/// (reference impl/thin/messages/base.pyx `_process_error_info`).
99#[derive(Clone, Debug, Default, Eq, PartialEq)]
100pub struct ServerErrorDetails {
101    pub message: String,
102    /// ORA error number (extended field).
103    pub code: u32,
104    /// Error position / parse offset (sb2; 0 when not reported).
105    pub pos: i32,
106    /// Server-reported row count at the time of the error.
107    pub row_count: u64,
108    /// Encoded rowid of the last affected row, if any.
109    pub rowid: Option<String>,
110    /// Row counts received before the error when
111    /// `executemany(arraydmlrowcounts=True)` was requested.
112    pub array_dml_row_counts: Option<Vec<u64>>,
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct ClientIdentity {
117    pub program: String,
118    pub machine: String,
119    pub osuser: String,
120    pub terminal: String,
121    pub driver_name: String,
122}
123
124impl ClientIdentity {
125    pub fn new(
126        program: impl Into<String>,
127        machine: impl Into<String>,
128        osuser: impl Into<String>,
129        terminal: impl Into<String>,
130        driver_name: impl Into<String>,
131    ) -> Result<Self> {
132        Ok(Self {
133            program: sanitize_identity_field("program", program.into())?,
134            machine: sanitize_identity_field("machine", machine.into())?,
135            osuser: sanitize_identity_field("osuser", osuser.into())?,
136            terminal: sanitize_identity_field("terminal", terminal.into())?,
137            driver_name: sanitize_identity_field("driver_name", driver_name.into())?,
138        })
139    }
140}
141
142fn sanitize_identity_field(field: &'static str, value: String) -> Result<String> {
143    let trimmed = value.trim();
144    if trimmed.is_empty() {
145        return Err(ProtocolError::InvalidClientIdentity {
146            field,
147            reason: Cow::Borrowed("value must not be empty"),
148        });
149    }
150
151    let mut out = String::with_capacity(trimmed.len().min(30));
152    for ch in trimmed.chars() {
153        if ch.is_control() {
154            return Err(ProtocolError::InvalidClientIdentity {
155                field,
156                reason: Cow::Borrowed("control characters are not allowed"),
157            });
158        }
159        if out.len() + ch.len_utf8() > 30 {
160            break;
161        }
162        out.push(ch);
163    }
164    Ok(out)
165}
166
167/// Fuzz-only thin wrappers over `pub(crate)` decoder entry points.
168///
169/// This module is compiled **only** under `--cfg fuzzing` (set automatically
170/// by `cargo-fuzz`). It exposes the crate-internal decode functions that take
171/// adversarial server bytes — the server-error trailer parser and the
172/// `pub(crate)` scalar codecs — so the `fuzz/` targets can call them directly
173/// without widening the normal public API. Each wrapper is a zero-logic
174/// forward to the real function; the goal is to prove these never panic on
175/// malformed input (they must fail closed with a [`ProtocolError`]).
176#[cfg(fuzzing)]
177pub mod fuzz_api {
178    use crate::wire::TtcReader;
179    use crate::Result;
180
181    /// Fuzz the server-error trailer parser (`parse_server_error_info`).
182    /// `ttc_field_version` is taken from the first input byte so the fuzzer
183    /// can explore both the legacy and 20.1+ trailer layouts.
184    pub fn fuzz_parse_server_error_info(data: &[u8]) -> Result<()> {
185        let (ttc_field_version, rest) = data.split_first().map_or((24u8, data), |(v, r)| (*v, r));
186        let mut reader = TtcReader::new(rest);
187        crate::thin::parse_server_error_info(&mut reader, ttc_field_version).map(|_| ())
188    }
189
190    /// Fuzz the server-side piggyback skipper (`skip_server_side_piggyback`).
191    pub fn fuzz_skip_server_side_piggyback(data: &[u8]) -> Result<()> {
192        let mut reader = TtcReader::new(data);
193        crate::thin::skip_server_side_piggyback(&mut reader).map(|_| ())
194    }
195
196    /// Fuzz every `pub(crate)` scalar codec that decodes raw column bytes.
197    /// Drives them all from one input so a single target covers the full
198    /// scalar surface (NUMBER, datetime, intervals, binary float/double).
199    pub fn fuzz_scalar_codecs(data: &[u8]) {
200        let _ = crate::thin::decode_number_value(data);
201        let _ = crate::thin::decode_datetime_value(data);
202        let _ = crate::thin::decode_interval_ds(data);
203        let _ = crate::thin::decode_interval_ym(data);
204        let _ = crate::thin::decode_binary_float(data);
205        let _ = crate::thin::decode_binary_double(data);
206    }
207
208    /// Fuzz the Advanced Queuing response decoders (enqueue / dequeue / array).
209    /// The first input byte selects the negotiated TTC field version and the
210    /// payload kind so the fuzzer can reach the RAW / JSON / Object branches;
211    /// the rest is the adversarial server payload. All three AQ parsers must
212    /// fail closed on any malformed input (they only `read_*` from a bounded
213    /// `TtcReader`, never index raw bytes).
214    pub fn fuzz_aq_responses(data: &[u8]) {
215        use crate::thin::aq::{
216            parse_aq_array_response, parse_aq_deq_response, parse_aq_enq_response, AqPayloadKind,
217        };
218        let (selector, payload) = data.split_first().map_or((0u8, data), |(v, r)| (*v, r));
219        let caps = crate::thin::ClientCapabilities {
220            ttc_field_version: 24 - (selector & 0x07),
221            ..crate::thin::ClientCapabilities::default()
222        };
223        let kind = match (selector >> 3) % 3 {
224            0 => AqPayloadKind::Raw,
225            1 => AqPayloadKind::Json,
226            _ => AqPayloadKind::Object,
227        };
228        let _ = parse_aq_enq_response(payload, caps);
229        let _ = parse_aq_deq_response(payload, caps, &kind);
230        // `operation` and `props_count` are derived from the selector so the
231        // array decoder explores both the dequeue-array and enqueue-array shapes.
232        let operation = i32::from(selector >> 6);
233        let props_count = u32::from(selector & 0x0f);
234        let _ = parse_aq_array_response(payload, caps, operation, props_count, &kind);
235    }
236
237    /// Fuzz the subscription (CQN/AQ-notification) response + notification
238    /// stream decoders. The first input byte drives the TTC field version, the
239    /// namespace, and the QoS flags so the fuzzer reaches the OAC-record and
240    /// grouping-notification branches. Both parsers must fail closed.
241    pub fn fuzz_subscr_responses(data: &[u8]) {
242        use crate::thin::{
243            parse_notification_stream, parse_subscribe_response, ClientCapabilities,
244        };
245        let (selector, payload) = data.split_first().map_or((0u8, data), |(v, r)| (*v, r));
246        let caps = ClientCapabilities {
247            ttc_field_version: 24 - (selector & 0x07),
248            ..ClientCapabilities::default()
249        };
250        let _ = parse_subscribe_response(payload, caps);
251        let namespace = u32::from(selector >> 4);
252        let public_qos = u32::from((selector >> 2) & 0x03);
253        let _ = parse_notification_stream(payload, namespace, public_qos, None);
254        let _ = parse_notification_stream(payload, namespace, public_qos, Some("FUZZDB"));
255    }
256
257    /// Fuzz the connect-string parsers on one untrusted string: the TNS
258    /// connect-descriptor / EZConnect-Plus parser
259    /// ([`crate::net::connectstring::parse`]) and the in-memory tnsnames.ora
260    /// lexer (`tnsnames::fuzz_parse_file`).
261    ///
262    /// Both consume untrusted env / config / user input and must *never*
263    /// panic / OOM / overflow the stack — only return `Err` (or, for the
264    /// descriptor case, `Ok(None)` meaning "this is a tnsnames alias"). The
265    /// descriptor recursion-depth DoS was fixed in bead `uf8`
266    /// (`MAX_DESCRIPTOR_DEPTH`); this entry point guards that fix and hunts
267    /// siblings in the EZConnect quote/host/port lexer and the tnsnames
268    /// comment / multi-line / paren-balancing tokenizer.
269    pub fn fuzz_connect_string(input: &str) {
270        let _ = crate::net::connectstring::parse(input);
271        let _ = crate::net::connectstring::tnsnames::fuzz_parse_file(input);
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn identity_fields_are_trimmed_and_bounded() {
281        let identity = ClientIdentity::new(
282            "  program-name-longer-than-thirty-bytes  ",
283            "machine",
284            "user",
285            "terminal",
286            "driver",
287        )
288        .expect("valid identity fields should sanitize");
289
290        assert_eq!(identity.program, "program-name-longer-than-thirt");
291        assert_eq!(identity.machine, "machine");
292    }
293
294    #[test]
295    fn identity_rejects_empty_fields() {
296        let err = ClientIdentity::new("", "machine", "user", "terminal", "driver")
297            .expect_err("empty program should be rejected");
298        assert!(matches!(
299            err,
300            ProtocolError::InvalidClientIdentity {
301                field: "program",
302                ..
303            }
304        ));
305    }
306}