Skip to main content

oracledb_protocol/thin/
fetch.rs

1#![forbid(unsafe_code)]
2
3use super::*;
4use crate::wire::ProtocolLimits;
5
6/// Validate `slice` as UTF-8 for the hot borrowed-text decode path, returning the
7/// borrowed `&str` on success or `()` on rejection (the caller falls back to the
8/// owned `TextRaw` carrier — semantics identical regardless of validator).
9///
10/// With the `simd-decode` feature this uses `simdutf8::basic::from_utf8`, whose
11/// accept/reject decision is byte-for-byte identical to `core::str::from_utf8`
12/// (it validates the exact same UTF-8 grammar — it only declines to compute the
13/// error *position*, which this path never uses). The crate stays
14/// `#![forbid(unsafe_code)]`-clean: `simdutf8`'s SIMD `unsafe` is encapsulated
15/// inside that dependency and we call only its safe API.
16#[inline]
17fn validate_utf8(slice: &[u8]) -> core::result::Result<&str, ()> {
18    #[cfg(feature = "simd-decode")]
19    {
20        simdutf8::basic::from_utf8(slice).map_err(|_| ())
21    }
22    #[cfg(not(feature = "simd-decode"))]
23    {
24        core::str::from_utf8(slice).map_err(|_| ())
25    }
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub(crate) enum LobDecodeMode {
30    PlainLocator,
31    DefineMetadata,
32}
33
34pub fn build_fetch_payload(cursor_id: u32, arraysize: u32, ttc_field_version: u8) -> Vec<u8> {
35    build_fetch_payload_with_seq(cursor_id, arraysize, 1, ttc_field_version)
36}
37
38pub fn build_fetch_payload_with_seq(
39    cursor_id: u32,
40    arraysize: u32,
41    seq_num: u8,
42    ttc_field_version: u8,
43) -> Vec<u8> {
44    // Fixed tiny payload (function code + ub8 + two ub4 ≈ <=20 bytes). Prealloc
45    // so the small pushes do not grow the Vec through doublings; built every
46    // fetch page, so this matters on multi-page fetches. Bytes unchanged.
47    let mut writer = TtcWriter::with_capacity(32);
48    writer.write_function_header(TNS_FUNC_FETCH, seq_num, ttc_field_version);
49    writer.write_ub4(cursor_id);
50    writer.write_ub4(arraysize);
51    writer.into_bytes()
52}
53
54pub fn build_define_fetch_payload_with_seq(
55    cursor_id: u32,
56    arraysize: u32,
57    seq_num: u8,
58    define_columns: &[ColumnMetadata],
59    ttc_field_version: u8,
60) -> Result<Vec<u8>> {
61    let define_count =
62        u32::try_from(define_columns.len()).map_err(|_| ProtocolError::InvalidPacketLength {
63            length: define_columns.len(),
64            minimum: 0,
65        })?;
66    let mut writer = TtcWriter::new();
67    writer.write_function_header(TNS_FUNC_EXECUTE, seq_num, ttc_field_version);
68    writer.write_ub4(TNS_EXEC_OPTION_DEFINE | TNS_EXEC_OPTION_NOT_PLSQL);
69    writer.write_ub4(cursor_id);
70    writer.write_u8(0);
71    writer.write_ub4(0);
72    writer.write_u8(1);
73    writer.write_ub4(13);
74    writer.write_u8(0);
75    writer.write_u8(0);
76    writer.write_ub4(0);
77    writer.write_ub4(arraysize);
78    writer.write_ub4(TNS_MAX_LONG_LENGTH);
79    writer.write_u8(0);
80    writer.write_ub4(0);
81    writer.write_u8(0);
82    writer.write_u8(0);
83    writer.write_u8(0);
84    writer.write_u8(0);
85    writer.write_u8(0);
86    writer.write_u8(1);
87    writer.write_ub4(define_count);
88    writer.write_ub4(0);
89    writer.write_u8(0);
90    writer.write_u8(1);
91    writer.write_u8(0);
92    writer.write_ub4(0);
93    writer.write_u8(0);
94    writer.write_ub4(0);
95    writer.write_ub4(0);
96    writer.write_u8(0);
97    writer.write_ub4(0);
98    writer.write_u8(0);
99    writer.write_u8(0);
100    writer.write_ub4(0);
101    writer.write_ub4(0);
102    writer.write_ub4(0);
103    writer.write_ub4(0);
104    writer.write_ub4(0);
105    writer.write_ub4(0);
106    writer.write_ub4(0);
107    writer.write_ub4(arraysize);
108    writer.write_ub4(0);
109    writer.write_ub4(0);
110    writer.write_ub4(0);
111    writer.write_ub4(0);
112    writer.write_ub4(0);
113    writer.write_ub4(1);
114    writer.write_ub4(0);
115    writer.write_ub4(0);
116    writer.write_ub4(0);
117    writer.write_ub4(0);
118    writer.write_ub4(0);
119    for metadata in define_columns {
120        write_define_column_metadata(&mut writer, metadata);
121    }
122    Ok(writer.into_bytes())
123}
124
125pub(crate) fn write_define_column_metadata(writer: &mut TtcWriter, metadata: &ColumnMetadata) {
126    // reference base.pyx: VECTOR (and JSON) columns advertise a LOB-prefetch
127    // buffer so the server streams the image inline rather than returning a
128    // bare temp-LOB locator
129    let (mut buffer_size, cont_flags, lob_prefetch_length) = match metadata.ora_type_num {
130        ORA_TYPE_NUM_CLOB | ORA_TYPE_NUM_BLOB => (metadata.buffer_size, TNS_LOB_PREFETCH_FLAG, 0),
131        ORA_TYPE_NUM_VECTOR => (
132            TNS_VECTOR_MAX_LENGTH,
133            TNS_LOB_PREFETCH_FLAG,
134            TNS_VECTOR_MAX_LENGTH,
135        ),
136        ORA_TYPE_NUM_JSON => (
137            TNS_JSON_MAX_LENGTH,
138            TNS_LOB_PREFETCH_FLAG,
139            TNS_JSON_MAX_LENGTH,
140        ),
141        _ => (metadata.buffer_size, 0, 0),
142    };
143    buffer_size = buffer_size.max(1);
144    writer.write_u8(metadata.ora_type_num);
145    writer.write_u8(TNS_BIND_USE_INDICATORS);
146    writer.write_u8(0);
147    writer.write_u8(0);
148    writer.write_ub4(buffer_size);
149    writer.write_ub4(0);
150    writer.write_ub8(cont_flags);
151    writer.write_ub4(0);
152    writer.write_ub2(0);
153    if metadata.csfrm != 0 {
154        writer.write_ub2(TNS_CHARSET_UTF8);
155    } else {
156        writer.write_ub2(0);
157    }
158    writer.write_u8(metadata.csfrm);
159    writer.write_ub4(lob_prefetch_length);
160    writer.write_ub4(0);
161}
162
163pub fn parse_query_response(
164    payload: &[u8],
165    capabilities: ClientCapabilities,
166) -> Result<QueryResult> {
167    parse_query_response_with_previous(payload, capabilities, None)
168}
169
170pub fn parse_query_response_with_limits(
171    payload: &[u8],
172    capabilities: ClientCapabilities,
173    limits: ProtocolLimits,
174) -> Result<QueryResult> {
175    parse_query_response_with_context_binds_options_and_limits(
176        payload,
177        capabilities,
178        &[],
179        None,
180        &[],
181        &[],
182        false,
183        ExecuteOptions::default(),
184        limits,
185    )
186}
187
188pub fn parse_query_response_with_binds(
189    payload: &[u8],
190    capabilities: ClientCapabilities,
191    binds: &[BindValue],
192) -> Result<QueryResult> {
193    parse_query_response_with_binds_and_options(
194        payload,
195        capabilities,
196        binds,
197        ExecuteOptions::default(),
198    )
199}
200
201pub fn parse_query_response_with_binds_and_options(
202    payload: &[u8],
203    capabilities: ClientCapabilities,
204    binds: &[BindValue],
205    exec_options: ExecuteOptions,
206) -> Result<QueryResult> {
207    parse_query_response_with_binds_options_and_columns(
208        payload,
209        capabilities,
210        binds,
211        exec_options,
212        &[],
213    )
214}
215
216/// `known_columns` carries the fetch metadata of a re-executed statement
217/// whose response does not repeat the describe information (reference keeps
218/// the statement's fetch vars across executions).
219pub fn parse_query_response_with_binds_options_and_columns(
220    payload: &[u8],
221    capabilities: ClientCapabilities,
222    binds: &[BindValue],
223    exec_options: ExecuteOptions,
224    known_columns: &[ColumnMetadata],
225) -> Result<QueryResult> {
226    let bind_columns = binds.iter().map(bind_column_metadata).collect::<Vec<_>>();
227    let output_bind_indexes = binds
228        .iter()
229        .enumerate()
230        .filter_map(|(index, value)| value.is_return_output().then_some(index))
231        .collect::<Vec<_>>();
232    parse_query_response_with_context_binds_and_options(
233        payload,
234        capabilities,
235        known_columns,
236        None,
237        &bind_columns,
238        &output_bind_indexes,
239        false,
240        exec_options,
241    )
242}
243
244pub fn parse_query_response_with_binds_options_columns_and_limits(
245    payload: &[u8],
246    capabilities: ClientCapabilities,
247    binds: &[BindValue],
248    exec_options: ExecuteOptions,
249    known_columns: &[ColumnMetadata],
250    limits: ProtocolLimits,
251) -> Result<QueryResult> {
252    limits.check_binds(binds.len())?;
253    let bind_columns = binds.iter().map(bind_column_metadata).collect::<Vec<_>>();
254    let output_bind_indexes = binds
255        .iter()
256        .enumerate()
257        .filter_map(|(index, value)| value.is_return_output().then_some(index))
258        .collect::<Vec<_>>();
259    parse_query_response_with_context_binds_options_and_limits(
260        payload,
261        capabilities,
262        known_columns,
263        None,
264        &bind_columns,
265        &output_bind_indexes,
266        false,
267        exec_options,
268        limits,
269    )
270}
271
272pub fn parse_query_response_with_previous(
273    payload: &[u8],
274    capabilities: ClientCapabilities,
275    previous_row: Option<&[Option<QueryValue>]>,
276) -> Result<QueryResult> {
277    parse_query_response_with_context(payload, capabilities, &[], previous_row)
278}
279
280pub fn parse_query_response_with_context(
281    payload: &[u8],
282    capabilities: ClientCapabilities,
283    previous_columns: &[ColumnMetadata],
284    previous_row: Option<&[Option<QueryValue>]>,
285) -> Result<QueryResult> {
286    parse_query_response_with_context_and_binds(
287        payload,
288        capabilities,
289        previous_columns,
290        previous_row,
291        &[],
292        &[],
293        false,
294        ProtocolLimits::DEFAULT,
295    )
296}
297
298pub fn parse_fetch_response_with_context(
299    payload: &[u8],
300    capabilities: ClientCapabilities,
301    previous_columns: &[ColumnMetadata],
302    previous_row: Option<&[Option<QueryValue>]>,
303) -> Result<QueryResult> {
304    parse_fetch_response_with_context_and_limits(
305        payload,
306        capabilities,
307        previous_columns,
308        previous_row,
309        ProtocolLimits::DEFAULT,
310    )
311}
312
313pub fn parse_fetch_response_with_context_and_limits(
314    payload: &[u8],
315    capabilities: ClientCapabilities,
316    previous_columns: &[ColumnMetadata],
317    previous_row: Option<&[Option<QueryValue>]>,
318    limits: ProtocolLimits,
319) -> Result<QueryResult> {
320    parse_query_response_with_context_binds_options_lob_mode_and_limits(
321        payload,
322        capabilities,
323        previous_columns,
324        previous_row,
325        &[],
326        &[],
327        true,
328        ExecuteOptions::default(),
329        LobDecodeMode::PlainLocator,
330        limits,
331    )
332}
333
334pub fn parse_define_fetch_response_with_context_and_limits(
335    payload: &[u8],
336    capabilities: ClientCapabilities,
337    previous_columns: &[ColumnMetadata],
338    previous_row: Option<&[Option<QueryValue>]>,
339    limits: ProtocolLimits,
340) -> Result<QueryResult> {
341    parse_query_response_with_context_binds_options_lob_mode_and_limits(
342        payload,
343        capabilities,
344        previous_columns,
345        previous_row,
346        &[],
347        &[],
348        true,
349        ExecuteOptions::default(),
350        LobDecodeMode::DefineMetadata,
351        limits,
352    )
353}
354
355#[allow(clippy::too_many_arguments)] // mirrors the reference message attribute set
356pub(crate) fn parse_query_response_with_context_and_binds(
357    payload: &[u8],
358    capabilities: ClientCapabilities,
359    previous_columns: &[ColumnMetadata],
360    previous_row: Option<&[Option<QueryValue>]>,
361    bind_columns: &[ColumnMetadata],
362    output_bind_indexes: &[usize],
363    fetch_long_status: bool,
364    limits: ProtocolLimits,
365) -> Result<QueryResult> {
366    parse_query_response_with_context_binds_options_and_limits(
367        payload,
368        capabilities,
369        previous_columns,
370        previous_row,
371        bind_columns,
372        output_bind_indexes,
373        fetch_long_status,
374        ExecuteOptions::default(),
375        limits,
376    )
377}
378
379#[allow(clippy::too_many_arguments)] // mirrors the reference message attribute set
380pub(crate) fn parse_query_response_with_context_binds_and_options(
381    payload: &[u8],
382    capabilities: ClientCapabilities,
383    previous_columns: &[ColumnMetadata],
384    previous_row: Option<&[Option<QueryValue>]>,
385    bind_columns: &[ColumnMetadata],
386    output_bind_indexes: &[usize],
387    fetch_long_status: bool,
388    exec_options: ExecuteOptions,
389) -> Result<QueryResult> {
390    parse_query_response_with_context_binds_options_and_limits(
391        payload,
392        capabilities,
393        previous_columns,
394        previous_row,
395        bind_columns,
396        output_bind_indexes,
397        fetch_long_status,
398        exec_options,
399        ProtocolLimits::DEFAULT,
400    )
401}
402
403#[allow(clippy::too_many_arguments)] // mirrors the reference message attribute set
404pub(crate) fn parse_query_response_with_context_binds_options_and_limits(
405    payload: &[u8],
406    capabilities: ClientCapabilities,
407    previous_columns: &[ColumnMetadata],
408    previous_row: Option<&[Option<QueryValue>]>,
409    bind_columns: &[ColumnMetadata],
410    output_bind_indexes: &[usize],
411    fetch_long_status: bool,
412    exec_options: ExecuteOptions,
413    limits: ProtocolLimits,
414) -> Result<QueryResult> {
415    parse_query_response_with_context_binds_options_lob_mode_and_limits(
416        payload,
417        capabilities,
418        previous_columns,
419        previous_row,
420        bind_columns,
421        output_bind_indexes,
422        fetch_long_status,
423        exec_options,
424        LobDecodeMode::DefineMetadata,
425        limits,
426    )
427}
428
429#[allow(clippy::too_many_arguments)] // mirrors the reference message attribute set
430fn parse_query_response_with_context_binds_options_lob_mode_and_limits(
431    payload: &[u8],
432    capabilities: ClientCapabilities,
433    previous_columns: &[ColumnMetadata],
434    previous_row: Option<&[Option<QueryValue>]>,
435    bind_columns: &[ColumnMetadata],
436    output_bind_indexes: &[usize],
437    fetch_long_status: bool,
438    exec_options: ExecuteOptions,
439    lob_decode_mode: LobDecodeMode,
440    limits: ProtocolLimits,
441) -> Result<QueryResult> {
442    let mut reader = TtcReader::with_limits(payload, limits)?;
443    let mut result = QueryResult {
444        columns: previous_columns.to_vec(),
445        more_rows: true,
446        ..QueryResult::default()
447    };
448    // A re-executed cursor whose column type changed to CLOB/BLOB but was
449    // previously fetched as CHAR/VARCHAR/RAW streams the value in LONG/LONG RAW
450    // form (see `adjust_refetch_metadata`), which carries the LONG status
451    // trailer (null indicator + return code) after each value — even on the
452    // execute path that otherwise passes `fetch_long_status = false`. Promote
453    // the flag when such an adjustment fires so `parse_row_data` consumes that
454    // trailer instead of mis-framing the next message (bead rust-oracledb-f0ad).
455    let mut fetch_long_status = fetch_long_status;
456    let mut bit_vector: Option<Vec<u8>> = None;
457    let mut out_bind_indexes: Vec<usize> = Vec::new();
458    while reader.remaining() > 0 {
459        let message_type = reader.read_u8()?;
460        match message_type {
461            0 => {}
462            TNS_MSG_TYPE_DESCRIBE_INFO => {
463                let _describe_name = reader.read_bytes()?;
464                let previous = std::mem::take(&mut result.columns);
465                parse_describe_info(&mut reader, capabilities, &mut result)?;
466                // re-executing an open cursor whose underlying types changed:
467                // the server re-describes mid-response but still streams the
468                // row data in the adjusted (LONG/LONG RAW) form expected by
469                // the previous fetch metadata (reference `_adjust_metadata`,
470                // impl/thin/messages/base.pyx:820-845, applied during
471                // `_process_describe_info`).
472                for (index, column) in result.columns.iter_mut().enumerate() {
473                    if let Some(prev) = previous.get(index) {
474                        if adjust_refetch_metadata(prev, column) {
475                            // the adjusted column (now LONG / LONG RAW) is
476                            // streamed with the LONG status trailer.
477                            fetch_long_status = true;
478                        }
479                    }
480                }
481            }
482            TNS_MSG_TYPE_ROW_HEADER => {
483                bit_vector = parse_row_header(&mut reader)?;
484            }
485            TNS_MSG_TYPE_ROW_DATA => {
486                if result.columns.is_empty() && !out_bind_indexes.is_empty() {
487                    parse_out_bind_row_data(
488                        &mut reader,
489                        &mut result,
490                        bind_columns,
491                        &out_bind_indexes,
492                    )?;
493                } else if result.columns.is_empty() && !output_bind_indexes.is_empty() {
494                    parse_returning_row_data(
495                        &mut reader,
496                        &mut result,
497                        bind_columns,
498                        output_bind_indexes,
499                    )?;
500                } else {
501                    parse_row_data(
502                        &mut reader,
503                        &mut result,
504                        bit_vector.as_deref(),
505                        previous_row,
506                        fetch_long_status,
507                        lob_decode_mode,
508                    )?;
509                }
510                bit_vector = None;
511            }
512            TNS_MSG_TYPE_BIT_VECTOR => {
513                bit_vector = Some(parse_bit_vector(&mut reader, result.columns.len())?);
514            }
515            TNS_MSG_TYPE_PARAMETER => {
516                let params =
517                    parse_query_return_parameters(&mut reader, exec_options.arraydmlrowcounts)?;
518                if exec_options.arraydmlrowcounts {
519                    result.array_dml_row_counts = Some(params.row_counts.unwrap_or_default());
520                }
521                if params.query_id.is_some() {
522                    result.query_id = params.query_id;
523                }
524            }
525            TNS_MSG_TYPE_STATUS => {
526                let call_status = reader.read_ub4()?;
527                let _seq = reader.read_ub2()?;
528                result.txn_in_progress = Some(call_status & TNS_EOCS_FLAGS_TXN_IN_PROGRESS != 0);
529            }
530            TNS_MSG_TYPE_IO_VECTOR => {
531                out_bind_indexes = parse_io_vector(&mut reader, bind_columns.len())?
532                    .into_iter()
533                    .filter(|index| !output_bind_indexes.contains(index))
534                    .collect();
535            }
536            TNS_MSG_TYPE_FLUSH_OUT_BINDS => break,
537            TNS_MSG_TYPE_SERVER_SIDE_PIGGYBACK => {
538                if let Some(update) = skip_server_side_piggyback(&mut reader)? {
539                    result.sessionless_txn_state = Some(update);
540                }
541            }
542            TNS_MSG_TYPE_IMPLICIT_RESULTSET => {
543                // reference messages/base.pyx `_process_implicit_result`
544                let num_results = reader.read_ub4()?;
545                // `num_results` is read straight off the wire (a ub4, up to
546                // ~4e9); each resultset consumes at least one byte, so cap the
547                // reservation by the bytes left in the payload (BoundedReader).
548                // Without this a hostile server forces a multi-gigabyte
549                // allocation (OOM) before the truncated read in the loop body
550                // fails closed.
551                let mut resultsets: Vec<QueryValue> = reader.with_capacity_limited(
552                    num_results as usize,
553                    1,
554                    ProtocolLimits::check_length_prefixed_elements,
555                )?;
556                for _ in 0..num_results {
557                    let num_bytes = reader.read_u8()?;
558                    reader.skip(usize::from(num_bytes))?;
559                    let mut child = QueryResult::default();
560                    parse_describe_info(&mut reader, capabilities, &mut child)?;
561                    let child_cursor_id = u32::from(reader.read_ub2()?);
562                    resultsets.push(QueryValue::Cursor(Box::new(CursorValue {
563                        columns: child.columns,
564                        cursor_id: child_cursor_id,
565                    })));
566                }
567                result.implicit_resultsets = Some(resultsets);
568            }
569            TNS_MSG_TYPE_END_OF_RESPONSE => break,
570            // pipeline responses open with the token of the operation they
571            // answer (messages/base.pyx:288-293); callers compare it against
572            // the expected token (mismatch -> DPY-2052 at the driver layer)
573            TNS_MSG_TYPE_TOKEN => {
574                result.token_num = Some(reader.read_ub8()?);
575            }
576            TNS_MSG_TYPE_ERROR => {
577                let info = parse_server_error_info(&mut reader, capabilities.ttc_field_version)?;
578                // The end-of-call ERROR message (number 0 on success) carries
579                // the end-of-call status; sample the transaction-in-progress bit
580                // (reference protocol.pyx `_process_call_status`).
581                result.txn_in_progress =
582                    Some(info.call_status & TNS_EOCS_FLAGS_TXN_IN_PROGRESS != 0);
583                if info.cursor_id != 0 {
584                    result.cursor_id = u32::from(info.cursor_id);
585                }
586                result.row_count = info.row_count;
587                result.compilation_error_warning |= info.compilation_error_warning;
588                result.last_rowid = info.rowid.clone();
589                if info.number == TNS_ERR_NO_DATA_FOUND && !result.columns.is_empty() {
590                    result.more_rows = false;
591                } else if info.number == TNS_ERR_ARRAY_DML_ERRORS {
592                    // executemany(batcherrors=True): errors are reported via
593                    // the batch error arrays instead of raising ORA-24381
594                    // (reference messages/base.pyx `_process_error_info`).
595                    result.batch_errors = info.batch_errors;
596                } else if info.number != 0 {
597                    let mut details = info.into_details();
598                    details.array_dml_row_counts = result.array_dml_row_counts.take();
599                    return Err(ProtocolError::ServerErrorInfo(Box::new(details)));
600                }
601            }
602            _ => {
603                let position = reader.position().saturating_sub(1);
604                if let Some(message) =
605                    find_embedded_server_error(payload, capabilities.ttc_field_version, position)
606                {
607                    return Err(ProtocolError::ServerError(message));
608                }
609                return Err(ProtocolError::UnknownMessageType {
610                    message_type,
611                    position,
612                });
613            }
614        }
615    }
616    Ok(result)
617}
618
619pub(crate) fn bind_column_metadata(value: &BindValue) -> ColumnMetadata {
620    let (ora_type_num, csfrm, buffer_size) = bind_metadata(value);
621    let object_schema = match value {
622        BindValue::ObjectOutput { schema, .. } | BindValue::ObjectInput { schema, .. } => {
623            Some(schema.clone())
624        }
625        _ => None,
626    };
627    let object_type_name = match value {
628        BindValue::ObjectOutput { type_name, .. } | BindValue::ObjectInput { type_name, .. } => {
629            Some(type_name.clone())
630        }
631        _ => None,
632    };
633    ColumnMetadata {
634        name: String::new(),
635        ora_type_num,
636        csfrm,
637        precision: 0,
638        scale: 0,
639        buffer_size,
640        max_size: buffer_size,
641        nulls_allowed: true,
642        is_json: false,
643        is_oson: false,
644        object_schema,
645        object_type_name,
646        is_array: matches!(value, BindValue::Array { .. }),
647        vector_dimensions: None,
648        vector_format: 0,
649        vector_flags: 0,
650        domain_schema: None,
651        domain_name: None,
652        annotations: None,
653    }
654}
655
656pub(crate) fn parse_io_vector(reader: &mut TtcReader<'_>, bind_count: usize) -> Result<Vec<usize>> {
657    let _flags = reader.read_u8()?;
658    let temp16 = reader.read_ub2()?;
659    let temp32 = reader.read_ub4()?;
660    let num_binds = usize::try_from(temp32)
661        .map_err(|_| ProtocolError::InvalidPacketLength {
662            length: usize::MAX,
663            minimum: 0,
664        })?
665        .checked_mul(256)
666        .and_then(|value| value.checked_add(usize::from(temp16)))
667        .ok_or(ProtocolError::InvalidPacketLength {
668            length: usize::MAX,
669            minimum: 0,
670        })?;
671    let _num_iters_this_time = reader.read_ub4()?;
672    let _uac_buffer_length = reader.read_ub2()?;
673    let fast_fetch_len = reader.read_ub2()?;
674    if fast_fetch_len > 0 {
675        reader.skip(usize::from(fast_fetch_len))?;
676    }
677    let rowid_len = reader.read_ub2()?;
678    if rowid_len > 0 {
679        reader.skip(usize::from(rowid_len))?;
680    }
681    let mut out_indexes = Vec::new();
682    reader.limits().check_binds(num_binds)?;
683    for index in 0..num_binds {
684        let direction = reader.read_u8()?;
685        if index < bind_count && direction != TNS_BIND_DIR_INPUT {
686            out_indexes.push(index);
687        }
688    }
689    Ok(out_indexes)
690}
691
692pub(crate) fn find_embedded_server_error(
693    payload: &[u8],
694    ttc_field_version: u8,
695    position: usize,
696) -> Option<String> {
697    let start = position.saturating_sub(64);
698    for candidate in start..=position {
699        if !matches!(payload.get(candidate).copied(), Some(TNS_MSG_TYPE_ERROR)) {
700            continue;
701        }
702        let mut reader = TtcReader::new(payload.get(candidate + 1..)?);
703        let info = parse_server_error_info(&mut reader, ttc_field_version).ok()?;
704        if info.number != 0 && info.message.starts_with("ORA-") {
705            return Some(info.message);
706        }
707    }
708    None
709}
710
711pub(crate) fn parse_describe_info(
712    reader: &mut TtcReader<'_>,
713    capabilities: ClientCapabilities,
714    result: &mut QueryResult,
715) -> Result<()> {
716    let _max_row_size = reader.read_ub4()?;
717    let num_columns = reader.read_ub4()?;
718    reader.limits().check_columns(num_columns as usize)?;
719    result.columns.clear();
720    if num_columns > 0 {
721        reader.skip(1)?;
722    }
723    for _ in 0..num_columns {
724        result
725            .columns
726            .push(parse_column_metadata(reader, capabilities)?);
727    }
728    let _current_date = reader.read_bytes_with_length()?;
729    let _dcbflag = reader.read_ub4()?;
730    let _dcbmdbz = reader.read_ub4()?;
731    let _dcbmnpr = reader.read_ub4()?;
732    let _dcbmxpr = reader.read_ub4()?;
733    let _dcbqcky = reader.read_bytes_with_length()?;
734    Ok(())
735}
736
737pub(crate) fn parse_column_metadata(
738    reader: &mut TtcReader<'_>,
739    capabilities: ClientCapabilities,
740) -> Result<ColumnMetadata> {
741    let ora_type_num = reader.read_u8()?;
742    reader.skip(1)?;
743    let precision = reader.read_i8()?;
744    let scale = reader.read_i8()?;
745    let buffer_size = reader.read_ub4()?;
746    let _max_array_elements = reader.read_ub4()?;
747    let _cont_flags = reader.read_ub8()?;
748    let _oid = reader.read_bytes_with_length()?;
749    let _version = reader.read_ub2()?;
750    let _charset_id = reader.read_ub2()?;
751    let csfrm = reader.read_u8()?;
752    let mut max_size = reader.read_ub4()?;
753    if ora_type_num == ORA_TYPE_NUM_RAW {
754        max_size = buffer_size;
755    }
756    if version_gates::carries_oaccolid(capabilities.ttc_field_version) {
757        let _oaccolid = reader.read_ub4()?;
758    }
759    let nulls_allowed = reader.read_u8()? != 0;
760    reader.skip(1)?;
761    let name = reader.read_string_with_length()?.unwrap_or_default();
762    let object_schema = reader.read_string_with_length()?;
763    let object_type_name = reader.read_string_with_length()?;
764    let _column_position = reader.read_ub2()?;
765    let uds_flags = reader.read_ub4()?;
766    let mut domain_schema = None;
767    let mut domain_name = None;
768    let mut annotations: Option<Vec<(String, String)>> = None;
769    if version_gates::reads_column_domain(capabilities.ttc_field_version) {
770        domain_schema = reader.read_string_with_length()?;
771        domain_name = reader.read_string_with_length()?;
772    }
773    if version_gates::reads_column_annotations(capabilities.ttc_field_version) {
774        let num_annotations = reader.read_ub4()?;
775        if num_annotations > 0 {
776            reader.skip(1)?;
777            let num_annotations = reader.read_ub4()?;
778            reader.skip(1)?;
779            // Bound by remaining bytes (BoundedReader): each annotation reads
780            // at least a length-prefixed key/value, so a ub4 count larger than
781            // the payload is a lie that must not pre-allocate gigabytes.
782            let mut collected: Vec<(String, String)> = reader.with_capacity_limited(
783                num_annotations as usize,
784                1,
785                ProtocolLimits::check_object_elements,
786            )?;
787            for _ in 0..num_annotations {
788                let key = reader.read_string_with_length()?.unwrap_or_default();
789                // A null annotation value is normalized to "" by the reference
790                // driver (python-oracledb base.pyx _process_metadata).
791                let value = reader.read_string_with_length()?.unwrap_or_default();
792                let _flags = reader.read_ub4()?;
793                collected.push((key, value));
794            }
795            let _flags = reader.read_ub4()?;
796            annotations = Some(collected);
797        }
798    }
799    let mut vector_dimensions = None;
800    let mut vector_format = 0u8;
801    let mut vector_flags = 0u8;
802    if version_gates::reads_column_vector_metadata(capabilities.ttc_field_version) {
803        // reference metadata.pyx: ub4 dimensions, ub1 format, ub1 flags
804        let dims = reader.read_ub4()?;
805        reader.limits().check_vector_dimensions(dims as usize)?;
806        vector_format = reader.read_u8()?;
807        vector_flags = reader.read_u8()?;
808        if ora_type_num == ORA_TYPE_NUM_VECTOR {
809            vector_dimensions = Some(dims);
810        }
811    }
812
813    Ok(ColumnMetadata {
814        name,
815        ora_type_num,
816        csfrm,
817        precision,
818        scale,
819        buffer_size,
820        max_size,
821        nulls_allowed,
822        is_json: uds_flags & TNS_UDS_FLAGS_IS_JSON != 0,
823        is_oson: uds_flags & TNS_UDS_FLAGS_IS_OSON != 0,
824        object_schema,
825        object_type_name,
826        is_array: false,
827        vector_dimensions,
828        vector_format,
829        vector_flags,
830        domain_schema,
831        domain_name,
832        annotations,
833    })
834}
835
836pub(crate) fn parse_row_header(reader: &mut TtcReader<'_>) -> Result<Option<Vec<u8>>> {
837    reader.skip(1)?;
838    let _num_requests = reader.read_ub2()?;
839    let _iteration_number = reader.read_ub4()?;
840    let _num_iters = reader.read_ub4()?;
841    let _buffer_length = reader.read_ub2()?;
842    let num_bytes = reader.read_ub4()?;
843    let bit_vector = if num_bytes > 0 {
844        reader.skip(1)?;
845        Some(reader.read_raw(num_bytes as usize)?.to_vec())
846    } else {
847        None
848    };
849    let _rxhrid = reader.read_bytes_with_length()?;
850    Ok(bit_vector)
851}
852
853pub(crate) fn parse_bit_vector(reader: &mut TtcReader<'_>, num_columns: usize) -> Result<Vec<u8>> {
854    let _num_columns_sent = reader.read_ub2()?;
855    let num_bytes = num_columns.div_ceil(8);
856    Ok(reader.read_raw(num_bytes)?.to_vec())
857}
858
859pub(crate) fn parse_row_data(
860    reader: &mut TtcReader<'_>,
861    result: &mut QueryResult,
862    bit_vector: Option<&[u8]>,
863    previous_row: Option<&[Option<QueryValue>]>,
864    fetch_long_status: bool,
865    lob_decode_mode: LobDecodeMode,
866) -> Result<()> {
867    let mut row = Vec::with_capacity(result.columns.len());
868    for (index, metadata) in result.columns.iter().enumerate() {
869        if is_duplicate_column(bit_vector, index) {
870            let previous = result
871                .rows
872                .last()
873                .map(Vec::as_slice)
874                .or(previous_row)
875                .and_then(|last| last.get(index))
876                .cloned()
877                .ok_or(ProtocolError::TtcDecode(
878                    "duplicate row data without previous row",
879                ))?;
880            row.push(previous);
881            continue;
882        }
883        row.push(parse_column_value_with_lob_mode(
884            reader,
885            metadata,
886            lob_decode_mode,
887        )?);
888        if fetch_long_status
889            && matches!(
890                metadata.ora_type_num,
891                ORA_TYPE_NUM_LONG | ORA_TYPE_NUM_LONG_RAW
892            )
893        {
894            let _null_indicator = reader.read_sb4()?;
895            let _return_code = reader.read_ub4()?;
896        }
897    }
898    result.rows.push(row);
899    Ok(())
900}
901
902pub(crate) fn parse_out_bind_row_data(
903    reader: &mut TtcReader<'_>,
904    result: &mut QueryResult,
905    bind_columns: &[ColumnMetadata],
906    out_bind_indexes: &[usize],
907) -> Result<()> {
908    for index in out_bind_indexes {
909        let metadata = bind_columns.get(*index).ok_or(ProtocolError::TtcDecode(
910            "out bind index without bind metadata",
911        ))?;
912        if metadata.is_array {
913            let num_elements = usize::try_from(reader.read_ub4()?).map_err(|_| {
914                ProtocolError::InvalidPacketLength {
915                    length: usize::MAX,
916                    minimum: 0,
917                }
918            })?;
919            reader.limits().check_batch_rows(num_elements)?;
920            // Cap by remaining bytes (BoundedReader): each element consumes
921            // wire data, so a ub4 count cannot legitimately exceed the payload.
922            let mut values: Vec<Option<QueryValue>> =
923                reader.with_capacity_limited(num_elements, 1, ProtocolLimits::check_batch_rows)?;
924            for _ in 0..num_elements {
925                let value = parse_column_value(reader, metadata)?;
926                let actual_num_bytes = reader.read_sb4()?;
927                values.push(apply_out_bind_actual_num_bytes(
928                    metadata,
929                    value,
930                    actual_num_bytes,
931                    "truncated array OUT bind value",
932                )?);
933            }
934            result
935                .out_values
936                .push((*index, Some(QueryValue::Array(values))));
937            continue;
938        }
939        let value = parse_column_value(reader, metadata)?;
940        let actual_num_bytes = reader.read_sb4()?;
941        result.out_values.push((
942            *index,
943            apply_out_bind_actual_num_bytes(
944                metadata,
945                value,
946                actual_num_bytes,
947                "truncated OUT bind value",
948            )?,
949        ));
950    }
951    Ok(())
952}
953
954pub(crate) fn parse_returning_row_data(
955    reader: &mut TtcReader<'_>,
956    result: &mut QueryResult,
957    bind_columns: &[ColumnMetadata],
958    output_bind_indexes: &[usize],
959) -> Result<()> {
960    for index in output_bind_indexes {
961        let metadata = bind_columns.get(*index).ok_or(ProtocolError::TtcDecode(
962            "return bind index without bind metadata",
963        ))?;
964        let num_rows = usize::try_from(reader.read_ub4()?).map_err(|_| {
965            ProtocolError::InvalidPacketLength {
966                length: usize::MAX,
967                minimum: 0,
968            }
969        })?;
970        reader.limits().check_batch_rows(num_rows)?;
971        // Cap by remaining bytes (BoundedReader); see the OOM note above.
972        let mut values: Vec<Option<QueryValue>> =
973            reader.with_capacity_limited(num_rows, 1, ProtocolLimits::check_batch_rows)?;
974        for _ in 0..num_rows {
975            let value = parse_column_value(reader, metadata)?;
976            let actual_num_bytes = reader.read_sb4()?;
977            values.push(apply_out_bind_actual_num_bytes(
978                metadata,
979                value,
980                actual_num_bytes,
981                "truncated DML RETURNING value",
982            )?);
983        }
984        result.return_values.push((*index, values));
985    }
986    Ok(())
987}
988
989fn apply_out_bind_actual_num_bytes(
990    metadata: &ColumnMetadata,
991    value: Option<QueryValue>,
992    actual_num_bytes: i32,
993    truncation_error: &'static str,
994) -> Result<Option<QueryValue>> {
995    if actual_num_bytes < 0 && metadata.ora_type_num == ORA_TYPE_NUM_BOOLEAN {
996        return Ok(None);
997    }
998    if actual_num_bytes != 0 && value.is_some() {
999        return Err(ProtocolError::TtcDecode(truncation_error));
1000    }
1001    Ok(value)
1002}
1003
1004pub(crate) fn is_duplicate_column(bit_vector: Option<&[u8]>, column_num: usize) -> bool {
1005    let Some(bit_vector) = bit_vector else {
1006        return false;
1007    };
1008    let byte_num = column_num / 8;
1009    let bit_num = column_num % 8;
1010    bit_vector
1011        .get(byte_num)
1012        .is_some_and(|byte| byte & (1 << bit_num) == 0)
1013}
1014
1015pub(crate) fn parse_column_value(
1016    reader: &mut TtcReader<'_>,
1017    metadata: &ColumnMetadata,
1018) -> Result<Option<QueryValue>> {
1019    parse_column_value_with_lob_mode(reader, metadata, LobDecodeMode::DefineMetadata)
1020}
1021
1022fn parse_column_value_with_lob_mode(
1023    reader: &mut TtcReader<'_>,
1024    metadata: &ColumnMetadata,
1025    lob_decode_mode: LobDecodeMode,
1026) -> Result<Option<QueryValue>> {
1027    if metadata.buffer_size == 0
1028        && !matches!(
1029            metadata.ora_type_num,
1030            ORA_TYPE_NUM_LONG | ORA_TYPE_NUM_LONG_RAW | ORA_TYPE_NUM_UROWID
1031        )
1032    {
1033        return Ok(None);
1034    }
1035    match metadata.ora_type_num {
1036        ORA_TYPE_NUM_VARCHAR | ORA_TYPE_NUM_CHAR | ORA_TYPE_NUM_LONG => {
1037            let Some(bytes) = reader.read_bytes()? else {
1038                return Ok(None);
1039            };
1040            match decode_text_value(&bytes, metadata.csfrm) {
1041                Ok(value) => Ok(Some(QueryValue::Text(value))),
1042                // preserve the raw bytes so the caller can honor the
1043                // configured encoding_errors policy (or raise a Python
1044                // UnicodeDecodeError as the reference does)
1045                Err(ProtocolError::TtcDecode(_)) => Ok(Some(QueryValue::TextRaw {
1046                    bytes,
1047                    csfrm: metadata.csfrm,
1048                })),
1049                Err(err) => Err(err),
1050            }
1051        }
1052        ORA_TYPE_NUM_RAW | ORA_TYPE_NUM_LONG_RAW => Ok(reader.read_bytes()?.map(QueryValue::Raw)),
1053        ORA_TYPE_NUM_ROWID => parse_rowid_value(reader).map(|value| value.map(QueryValue::Rowid)),
1054        ORA_TYPE_NUM_UROWID => parse_urowid_value(reader).map(|value| value.map(QueryValue::Rowid)),
1055        ORA_TYPE_NUM_NUMBER | ORA_TYPE_NUM_BINARY_INTEGER => {
1056            let Some(bytes) = reader.read_bytes()? else {
1057                return Ok(None);
1058            };
1059            decode_number_value(&bytes).map(Some)
1060        }
1061        ORA_TYPE_NUM_BINARY_DOUBLE => {
1062            let Some(bytes) = reader.read_bytes()? else {
1063                return Ok(None);
1064            };
1065            decode_binary_double(&bytes)
1066                .map(|value| Some(QueryValue::BinaryDouble(value.to_string())))
1067        }
1068        ORA_TYPE_NUM_BINARY_FLOAT => {
1069            let Some(bytes) = reader.read_bytes()? else {
1070                return Ok(None);
1071            };
1072            // f64-widened text matches Python float semantics for BINARY_FLOAT
1073            decode_binary_float(&bytes)
1074                .map(|value| Some(QueryValue::BinaryDouble(f64::from(value).to_string())))
1075        }
1076        ORA_TYPE_NUM_BOOLEAN => {
1077            let Some(bytes) = reader.read_bytes()? else {
1078                return Ok(None);
1079            };
1080            // reference read_bool: last byte == 1 means true; native
1081            // DB_TYPE_BOOLEAN surfaces as a Python bool.
1082            let is_true = matches!(bytes.last(), Some(&1));
1083            Ok(Some(QueryValue::Boolean(is_true)))
1084        }
1085        ORA_TYPE_NUM_INTERVAL_DS => {
1086            let Some(bytes) = reader.read_bytes()? else {
1087                return Ok(None);
1088            };
1089            decode_interval_ds(&bytes).map(Some)
1090        }
1091        ORA_TYPE_NUM_INTERVAL_YM => {
1092            let Some(bytes) = reader.read_bytes()? else {
1093                return Ok(None);
1094            };
1095            decode_interval_ym(&bytes).map(Some)
1096        }
1097        ORA_TYPE_NUM_DATE
1098        | ORA_TYPE_NUM_TIMESTAMP
1099        | ORA_TYPE_NUM_TIMESTAMP_LTZ
1100        | ORA_TYPE_NUM_TIMESTAMP_TZ => {
1101            let Some(bytes) = reader.read_bytes()? else {
1102                return Ok(None);
1103            };
1104            decode_datetime_value(&bytes).map(Some)
1105        }
1106        ORA_TYPE_NUM_CLOB | ORA_TYPE_NUM_BLOB | ORA_TYPE_NUM_BFILE => {
1107            parse_lob_value(reader, metadata, lob_decode_mode)
1108        }
1109        ORA_TYPE_NUM_VECTOR => parse_vector_value(reader),
1110        ORA_TYPE_NUM_JSON => parse_json_value(reader),
1111        ORA_TYPE_NUM_CURSOR => parse_cursor_value(reader).map(Some),
1112        ORA_TYPE_NUM_OBJECT => parse_object_value(reader, metadata),
1113        _ => Err(ProtocolError::UnsupportedFeature("query column type")),
1114    }
1115}
1116
1117/// A column value decoded in pass 1 of the borrowed row decode. Scalar values
1118/// that borrow the wire buffer are held directly; values that need a small
1119/// owned arena (synthesized `Number` text, or a cold owned [`QueryValue`]) are
1120/// recorded as a deferred handle into the per-row arena and resolved in pass 2
1121/// once the arena is frozen. This two-pass split is what keeps the borrowed
1122/// path sound under `#![forbid(unsafe_code)]`: no `&str`/`&[u8]` is ever held
1123/// into an arena that is still being grown.
1124enum ColumnSlot<'buf> {
1125    /// SQL NULL.
1126    Null,
1127    /// A value that borrows the wire buffer (or is a small `Copy` value).
1128    Wire(QueryValueRef<'buf>),
1129    /// A `NUMBER` whose canonical text lives at `arena[range]` in the per-row
1130    /// number-text arena.
1131    Number {
1132        range: core::ops::Range<usize>,
1133        is_integer: bool,
1134    },
1135    /// A cold / non-borrowable value parked at `owned[index]` in the per-row
1136    /// owned arena.
1137    Owned(usize),
1138}
1139
1140/// Decode one column into a [`ColumnSlot`], borrowing the wire buffer for the
1141/// hot scalar cases and appending to the per-row arenas for the deferred ones.
1142/// Mirrors [`parse_column_value`] type-for-type; the produced owned value (via
1143/// [`QueryValueRef::to_owned_value`]) is identical to the owned path.
1144///
1145/// `digits` is a caller-owned scratch buffer reused across all cells so the
1146/// per-cell `NUMBER` decode allocates nothing of its own (it writes straight
1147/// into `number_arena`). The hot scalar grid (Text/Raw) borrows the wire buffer
1148/// directly with zero allocation.
1149fn parse_column_slot<'buf>(
1150    reader: &mut TtcReader<'buf>,
1151    metadata: &ColumnMetadata,
1152    number_arena: &mut String,
1153    owned_arena: &mut Vec<QueryValue>,
1154    digits: &mut Vec<u8>,
1155    lob_decode_mode: LobDecodeMode,
1156) -> Result<ColumnSlot<'buf>> {
1157    // Park an owned QueryValue in the arena and return the deferred slot. Used
1158    // for the cold / non-borrowable variants so the hot grid stays borrowed.
1159    fn park(owned_arena: &mut Vec<QueryValue>, value: Option<QueryValue>) -> ColumnSlot<'static> {
1160        match value {
1161            None => ColumnSlot::Null,
1162            Some(value) => {
1163                owned_arena.push(value);
1164                ColumnSlot::Owned(owned_arena.len() - 1)
1165            }
1166        }
1167    }
1168
1169    if metadata.buffer_size == 0
1170        && !matches!(
1171            metadata.ora_type_num,
1172            ORA_TYPE_NUM_LONG | ORA_TYPE_NUM_LONG_RAW | ORA_TYPE_NUM_UROWID
1173        )
1174    {
1175        return Ok(ColumnSlot::Null);
1176    }
1177    match metadata.ora_type_num {
1178        ORA_TYPE_NUM_VARCHAR | ORA_TYPE_NUM_CHAR | ORA_TYPE_NUM_LONG => {
1179            match reader.read_bytes_borrowed()? {
1180                BorrowedBytes::Null => Ok(ColumnSlot::Null),
1181                // Borrow the wire bytes directly when they are valid UTF-8 and
1182                // not the UTF-16 NCHAR form (which needs re-encoding). Zero copy.
1183                BorrowedBytes::Slice(slice) if metadata.csfrm != CS_FORM_NCHAR => {
1184                    match validate_utf8(slice) {
1185                        Ok(text) => Ok(ColumnSlot::Wire(QueryValueRef::Text(text))),
1186                        Err(_) => Ok(park(
1187                            owned_arena,
1188                            Some(QueryValue::TextRaw {
1189                                bytes: slice.to_vec(),
1190                                csfrm: metadata.csfrm,
1191                            }),
1192                        )),
1193                    }
1194                }
1195                // NCHAR (UTF-16) or chunked long text: fall back to the owned
1196                // decode, which re-encodes to UTF-8 / reassembles chunks.
1197                other => {
1198                    let bytes = other.into_vec();
1199                    let value = match decode_text_value(&bytes, metadata.csfrm) {
1200                        Ok(text) => QueryValue::Text(text),
1201                        Err(ProtocolError::TtcDecode(_)) => QueryValue::TextRaw {
1202                            bytes,
1203                            csfrm: metadata.csfrm,
1204                        },
1205                        Err(err) => return Err(err),
1206                    };
1207                    Ok(park(owned_arena, Some(value)))
1208                }
1209            }
1210        }
1211        ORA_TYPE_NUM_RAW | ORA_TYPE_NUM_LONG_RAW => match reader.read_bytes_borrowed()? {
1212            BorrowedBytes::Null => Ok(ColumnSlot::Null),
1213            BorrowedBytes::Slice(slice) => Ok(ColumnSlot::Wire(QueryValueRef::Raw(slice))),
1214            BorrowedBytes::Chunked(bytes) => Ok(park(owned_arena, Some(QueryValue::Raw(bytes)))),
1215        },
1216        ORA_TYPE_NUM_NUMBER | ORA_TYPE_NUM_BINARY_INTEGER => {
1217            // The wire NUMBER is binary; its canonical decimal text is
1218            // synthesized, so it cannot be borrowed from the buffer. We
1219            // synthesize it *directly* into the per-row number arena (reusing the
1220            // `digits` scratch), borrowing from the arena — zero per-cell heap
1221            // allocation for the common in-range NUMBER.
1222            with_small_bytes(reader, |bytes| match bytes {
1223                None => Ok(ColumnSlot::Null),
1224                Some(bytes) => {
1225                    let start = number_arena.len();
1226                    let is_integer = decode_number_text_into(bytes, digits, number_arena)?;
1227                    Ok(ColumnSlot::Number {
1228                        range: start..number_arena.len(),
1229                        is_integer,
1230                    })
1231                }
1232            })
1233        }
1234        ORA_TYPE_NUM_BOOLEAN => with_small_bytes(reader, |bytes| match bytes {
1235            None => Ok(ColumnSlot::Null),
1236            Some(bytes) => Ok(ColumnSlot::Wire(QueryValueRef::Boolean(matches!(
1237                bytes.last(),
1238                Some(&1)
1239            )))),
1240        }),
1241        ORA_TYPE_NUM_INTERVAL_DS => with_small_bytes(reader, |bytes| match bytes {
1242            None => Ok(ColumnSlot::Null),
1243            Some(bytes) => match decode_interval_ds(bytes)? {
1244                QueryValue::IntervalDS {
1245                    days,
1246                    hours,
1247                    minutes,
1248                    seconds,
1249                    fseconds,
1250                } => Ok(ColumnSlot::Wire(QueryValueRef::IntervalDS {
1251                    days,
1252                    hours,
1253                    minutes,
1254                    seconds,
1255                    fseconds,
1256                })),
1257                other => Ok(park(owned_arena, Some(other))),
1258            },
1259        }),
1260        ORA_TYPE_NUM_INTERVAL_YM => with_small_bytes(reader, |bytes| match bytes {
1261            None => Ok(ColumnSlot::Null),
1262            Some(bytes) => match decode_interval_ym(bytes)? {
1263                QueryValue::IntervalYM { years, months } => {
1264                    Ok(ColumnSlot::Wire(QueryValueRef::IntervalYM {
1265                        years,
1266                        months,
1267                    }))
1268                }
1269                other => Ok(park(owned_arena, Some(other))),
1270            },
1271        }),
1272        ORA_TYPE_NUM_DATE
1273        | ORA_TYPE_NUM_TIMESTAMP
1274        | ORA_TYPE_NUM_TIMESTAMP_LTZ
1275        | ORA_TYPE_NUM_TIMESTAMP_TZ => with_small_bytes(reader, |bytes| match bytes {
1276            None => Ok(ColumnSlot::Null),
1277            Some(bytes) => match decode_datetime_value(bytes)? {
1278                QueryValue::DateTime {
1279                    year,
1280                    month,
1281                    day,
1282                    hour,
1283                    minute,
1284                    second,
1285                    nanosecond,
1286                } => Ok(ColumnSlot::Wire(QueryValueRef::DateTime {
1287                    year,
1288                    month,
1289                    day,
1290                    hour,
1291                    minute,
1292                    second,
1293                    nanosecond,
1294                })),
1295                QueryValue::TimestampTz {
1296                    year,
1297                    month,
1298                    day,
1299                    hour,
1300                    minute,
1301                    second,
1302                    nanosecond,
1303                    offset_minutes,
1304                } => Ok(ColumnSlot::Wire(QueryValueRef::TimestampTz {
1305                    year,
1306                    month,
1307                    day,
1308                    hour,
1309                    minute,
1310                    second,
1311                    nanosecond,
1312                    offset_minutes,
1313                })),
1314                other => Ok(park(owned_arena, Some(other))),
1315            },
1316        }),
1317        // Everything else (Rowid, BinaryDouble/Float, Clob/Blob/Bfile, Vector,
1318        // Json, Cursor, Object, UROWID) goes through the owned decode and is
1319        // parked in the owned arena. These are the cold / non-borrowable cases.
1320        _ => {
1321            let value = parse_column_value_with_lob_mode(reader, metadata, lob_decode_mode)?;
1322            Ok(park(owned_arena, value))
1323        }
1324    }
1325}
1326
1327/// Read one TTC byte field and hand the body to `f` as a borrowed `&[u8]`
1328/// without allocating in the common contiguous case. `None` is SQL NULL. The
1329/// rare chunked long form is reassembled into a temporary `Vec` (these small
1330/// fixed-size scalar types — number/boolean/interval/datetime — are never sent
1331/// chunked in practice, so this fallback is effectively dead weight).
1332fn with_small_bytes<'buf, T>(
1333    reader: &mut TtcReader<'buf>,
1334    f: impl FnOnce(Option<&[u8]>) -> Result<T>,
1335) -> Result<T> {
1336    match reader.read_bytes_borrowed()? {
1337        BorrowedBytes::Null => f(None),
1338        BorrowedBytes::Slice(slice) => f(Some(slice)),
1339        BorrowedBytes::Chunked(owned) => f(Some(&owned)),
1340    }
1341}
1342
1343impl BorrowedBytes<'_> {
1344    /// Reassemble into an owned `Vec` (zero-copy `Slice` still copies; `Chunked`
1345    /// reuses its already-owned `Vec`). Used by the non-borrowable text fallback.
1346    fn into_vec(self) -> Vec<u8> {
1347        match self {
1348            BorrowedBytes::Null => Vec::new(),
1349            BorrowedBytes::Slice(slice) => slice.to_vec(),
1350            BorrowedBytes::Chunked(owned) => owned,
1351        }
1352    }
1353}
1354
1355/// A decoded fetch batch that **owns** the wire response buffer and column
1356/// metadata, and yields rows of borrowed [`QueryValueRef`] that point straight
1357/// into that buffer. This is the zero-copy fetch fast path: the common scalar
1358/// grid is decoded with no per-cell allocation.
1359///
1360/// ## Soundness
1361///
1362/// The buffer is owned by the batch and outlives every borrowed row: rows are
1363/// only ever surfaced *inside* the [`for_each_row_ref`](Self::for_each_row_ref)
1364/// callback, whose `&[QueryValueRef]` argument cannot escape (its lifetime is
1365/// bound to the call). The borrow checker therefore guarantees no
1366/// `QueryValueRef` can dangle — there is no self-referential struct and no
1367/// `unsafe`. `Number` text and the cold values borrow per-row arenas that are
1368/// fully built (pass 1) before any reference into them is taken (pass 2), so an
1369/// arena is never grown while borrowed.
1370#[derive(Clone, Debug)]
1371pub struct BorrowedRowBatch {
1372    buffer: Vec<u8>,
1373    columns: Vec<ColumnMetadata>,
1374    /// Byte offset into `buffer` where each row's column values begin.
1375    row_starts: Vec<usize>,
1376    /// Per-row duplicate-column bit vector (server row-compression). `None` (or
1377    /// an absent entry) means every column is present on the wire for that row.
1378    /// A zero bit marks a duplicate column whose value repeats the previous
1379    /// row's value and carries no wire bytes (reference `bit_vector`).
1380    row_bit_vectors: Vec<Option<Vec<u8>>>,
1381    /// Whether this batch carried `LONG`/`LONG RAW` status trailers after each
1382    /// such column (the fetch path sets this; the plain execute path does not).
1383    fetch_long_status: bool,
1384    lob_decode_mode: LobDecodeMode,
1385    /// The caller's previous (owned) row, used to resolve duplicate columns in
1386    /// the *first* compressed row of the batch (whose duplicates repeat the row
1387    /// that ended the prior page). `None` outside the compressed-fetch case.
1388    previous_row_seed: Option<Vec<Option<QueryValue>>>,
1389}
1390
1391impl BorrowedRowBatch {
1392    /// Construct a batch from an owned wire `buffer`, the `columns` describing
1393    /// each cell, and the per-row start offsets into `buffer`. Use this for
1394    /// batches with no duplicate-column compression and no LONG trailers (the
1395    /// common synthetic / test case); the framing-aware
1396    /// [`parse_query_response_borrowed`] builds the full form.
1397    pub fn new(buffer: Vec<u8>, columns: Vec<ColumnMetadata>, row_starts: Vec<usize>) -> Self {
1398        Self {
1399            buffer,
1400            columns,
1401            row_starts,
1402            row_bit_vectors: Vec::new(),
1403            fetch_long_status: false,
1404            lob_decode_mode: LobDecodeMode::PlainLocator,
1405            previous_row_seed: None,
1406        }
1407    }
1408
1409    /// Number of rows in the batch.
1410    pub fn row_count(&self) -> usize {
1411        self.row_starts.len()
1412    }
1413
1414    /// The columns describing each cell.
1415    pub fn columns(&self) -> &[ColumnMetadata] {
1416        &self.columns
1417    }
1418
1419    /// The address range of the owned buffer, for tests asserting that borrowed
1420    /// scalar cells truly point into it (zero-copy).
1421    #[cfg(test)]
1422    pub fn buffer_ptr_range(&self) -> core::ops::Range<usize> {
1423        let start = self.buffer.as_ptr() as usize;
1424        start..start + self.buffer.len()
1425    }
1426
1427    /// Decode each row and invoke `callback` with the row's borrowed cells. The
1428    /// `&[Option<QueryValueRef>]` slice borrows the batch buffer and per-row
1429    /// arenas; it is valid only for the duration of the call (it cannot escape).
1430    /// `None` cells are SQL NULL. Returns the first decode/callback error.
1431    ///
1432    /// Generic over the callback's error type `E` (any error a decode failure
1433    /// can convert into, e.g. the driver crate's own error) so callers are not
1434    /// forced through [`ProtocolError`]; a decode failure is surfaced via
1435    /// `E: From<ProtocolError>`.
1436    pub fn for_each_row_ref<F, E>(&self, mut callback: F) -> std::result::Result<(), E>
1437    where
1438        F: FnMut(&[Option<QueryValueRef<'_>>]) -> std::result::Result<(), E>,
1439        E: From<ProtocolError>,
1440    {
1441        // The two arenas are reused across rows (cleared, not reallocated). They
1442        // are mutated only in pass 1; pass 2 borrows them immutably and that
1443        // borrow is confined to a single loop iteration (it ends before the next
1444        // iteration's `clear()`), which is what keeps the borrow checker — and
1445        // soundness — happy.
1446        let mut number_arena = String::new();
1447        let mut owned_arena: Vec<QueryValue> = Vec::new();
1448        // Reusable scratch: `digits` for the NUMBER decode, `slots` for pass 1,
1449        // `row` for the borrowed cells handed to the callback. All cleared and
1450        // reused across rows so the steady-state per-row decode allocates only
1451        // when an arena/scratch genuinely grows (amortized). `slots`/`row`/
1452        // `digits` borrow nothing across iterations beyond the stable buffer.
1453        let mut digits: Vec<u8> = Vec::new();
1454        let mut slots: Vec<Option<ColumnSlot<'_>>> = Vec::with_capacity(self.columns.len());
1455        // Owned snapshot of the previous row, used only to resolve duplicate
1456        // (bit-vector-compressed) columns, which carry no wire bytes. Empty when
1457        // the batch has no bit vectors (the common case), so it costs nothing.
1458        // Seeded from the caller's prior-page row for the first compressed row.
1459        let mut previous_owned: Vec<Option<QueryValue>> =
1460            self.previous_row_seed.clone().unwrap_or_default();
1461        let uses_bit_vectors = !self.row_bit_vectors.is_empty();
1462
1463        for (row_index, &start) in self.row_starts.iter().enumerate() {
1464            number_arena.clear();
1465            owned_arena.clear();
1466            slots.clear();
1467            let bit_vector = self
1468                .row_bit_vectors
1469                .get(row_index)
1470                .and_then(|bv| bv.as_deref());
1471
1472            // Pass 1: decode all columns, growing the per-row arenas. `slots`
1473            // borrows only the buffer (the deferred Number/Owned slots hold a
1474            // range/index into the arenas, never a borrow of them). Duplicate
1475            // columns carry no wire bytes — their owned previous value is parked
1476            // in the owned arena.
1477            let mut reader = TtcReader::new(&self.buffer[start..]);
1478            for (index, metadata) in self.columns.iter().enumerate() {
1479                if is_duplicate_column(bit_vector, index) {
1480                    let previous = previous_owned.get(index).and_then(Option::as_ref);
1481                    match previous {
1482                        None => slots.push(None),
1483                        Some(value) => {
1484                            owned_arena.push(value.clone());
1485                            slots.push(Some(ColumnSlot::Owned(owned_arena.len() - 1)));
1486                        }
1487                    }
1488                    continue;
1489                }
1490                let slot = parse_column_slot(
1491                    &mut reader,
1492                    metadata,
1493                    &mut number_arena,
1494                    &mut owned_arena,
1495                    &mut digits,
1496                    self.lob_decode_mode,
1497                )?;
1498                slots.push(match slot {
1499                    ColumnSlot::Null => None,
1500                    other => Some(other),
1501                });
1502                if self.fetch_long_status
1503                    && matches!(
1504                        metadata.ora_type_num,
1505                        ORA_TYPE_NUM_LONG | ORA_TYPE_NUM_LONG_RAW
1506                    )
1507                {
1508                    let _null_indicator = reader.read_sb4()?;
1509                    let _return_code = reader.read_ub4()?;
1510                }
1511            }
1512
1513            // Pass 2: arenas are now frozen — resolve deferred slots into
1514            // borrowed refs. No arena is mutated here, so the borrows are sound.
1515            // `row` is allocated per row: it carries the per-row arena lifetime,
1516            // which (unlike `slots`/`digits`, that borrow only the stable buffer)
1517            // cannot be reused across an arena `clear()`. This is the single
1518            // remaining per-row allocation, versus the owned path's per-row Vec
1519            // *plus* a String per scalar cell.
1520            let row: Vec<Option<QueryValueRef<'_>>> = slots
1521                .iter()
1522                .map(|slot| {
1523                    slot.as_ref().map(|slot| match *slot {
1524                        ColumnSlot::Null => unreachable!("Null slots are stored as None"),
1525                        ColumnSlot::Wire(value) => value,
1526                        ColumnSlot::Number {
1527                            ref range,
1528                            is_integer,
1529                        } => QueryValueRef::Number {
1530                            text: &number_arena[range.clone()],
1531                            is_integer,
1532                        },
1533                        ColumnSlot::Owned(index) => QueryValueRef::Owned(&owned_arena[index]),
1534                    })
1535                })
1536                .collect();
1537
1538            callback(&row)?;
1539
1540            // Snapshot the just-emitted row as owned values for the next row's
1541            // duplicate-column resolution — but only when the batch actually uses
1542            // bit-vector compression, so the zero-copy common path pays nothing.
1543            if uses_bit_vectors {
1544                previous_owned.clear();
1545                previous_owned.extend(row.iter().map(|cell| cell.map(|v| v.to_owned_value())));
1546            }
1547        }
1548        Ok(())
1549    }
1550}
1551
1552/// The borrowed counterpart of a fetched [`QueryResult`]: a [`BorrowedRowBatch`]
1553/// of zero-copy rows plus the response-level fields a caller needs to page and
1554/// finalize the cursor. Produced by [`parse_query_response_borrowed`].
1555#[derive(Clone, Debug)]
1556pub struct BorrowedFetchResult {
1557    /// The decoded rows, borrowing the response buffer.
1558    pub batch: BorrowedRowBatch,
1559    /// Whether the server reports more rows for this cursor.
1560    pub more_rows: bool,
1561    /// Server cursor id (for paging / release).
1562    pub cursor_id: u32,
1563    /// Total affected/processed row count from the end-of-call error message.
1564    pub row_count: u64,
1565}
1566
1567/// Walk a fetch/query response payload and produce a [`BorrowedFetchResult`]
1568/// whose rows borrow `payload` (the caller must keep the owned buffer alive —
1569/// [`BorrowedRowBatch`] owns it). This is the zero-copy companion to
1570/// [`parse_fetch_response_with_context`]: it walks the exact same message
1571/// framing (DESCRIBE_INFO / ROW_HEADER / BIT_VECTOR / ROW_DATA / ERROR /
1572/// END_OF_RESPONSE) but, instead of materializing owned rows, records each
1573/// row's byte offset and bit vector so [`BorrowedRowBatch::for_each_row_ref`]
1574/// can decode them lazily and without per-cell allocation.
1575///
1576/// Scope: the plain query-row case (the fetch path). Out-bind / DML-returning
1577/// rows are not part of a fetch response and are left to the owned path.
1578pub fn parse_query_response_borrowed(
1579    payload: &[u8],
1580    capabilities: ClientCapabilities,
1581    columns: &[ColumnMetadata],
1582    previous_row: Option<&[Option<QueryValue>]>,
1583) -> Result<BorrowedFetchResult> {
1584    parse_query_response_borrowed_with_limits(
1585        payload,
1586        capabilities,
1587        columns,
1588        previous_row,
1589        ProtocolLimits::DEFAULT,
1590    )
1591}
1592
1593pub fn parse_query_response_borrowed_with_limits(
1594    payload: &[u8],
1595    capabilities: ClientCapabilities,
1596    columns: &[ColumnMetadata],
1597    previous_row: Option<&[Option<QueryValue>]>,
1598    limits: ProtocolLimits,
1599) -> Result<BorrowedFetchResult> {
1600    parse_query_response_borrowed_with_lob_mode_and_limits(
1601        payload,
1602        capabilities,
1603        columns,
1604        previous_row,
1605        LobDecodeMode::PlainLocator,
1606        limits,
1607    )
1608}
1609
1610pub fn parse_define_fetch_response_borrowed_with_limits(
1611    payload: &[u8],
1612    capabilities: ClientCapabilities,
1613    columns: &[ColumnMetadata],
1614    previous_row: Option<&[Option<QueryValue>]>,
1615    limits: ProtocolLimits,
1616) -> Result<BorrowedFetchResult> {
1617    parse_query_response_borrowed_with_lob_mode_and_limits(
1618        payload,
1619        capabilities,
1620        columns,
1621        previous_row,
1622        LobDecodeMode::DefineMetadata,
1623        limits,
1624    )
1625}
1626
1627fn parse_query_response_borrowed_with_lob_mode_and_limits(
1628    payload: &[u8],
1629    capabilities: ClientCapabilities,
1630    columns: &[ColumnMetadata],
1631    previous_row: Option<&[Option<QueryValue>]>,
1632    lob_decode_mode: LobDecodeMode,
1633    limits: ProtocolLimits,
1634) -> Result<BorrowedFetchResult> {
1635    let mut reader = TtcReader::with_limits(payload, limits)?;
1636    reader.limits().check_columns(columns.len())?;
1637    let mut result_columns = columns.to_vec();
1638    let mut more_rows = true;
1639    let mut cursor_id = 0u32;
1640    let mut row_count = 0u64;
1641    let mut row_starts: Vec<usize> = Vec::new();
1642    let mut row_bit_vectors: Vec<Option<Vec<u8>>> = Vec::new();
1643    let mut any_bit_vector = false;
1644    let mut pending_bit_vector: Option<Vec<u8>> = None;
1645    // The fetch path always consumes LONG/LONG RAW status trailers.
1646    let fetch_long_status = true;
1647
1648    while reader.remaining() > 0 {
1649        let message_type = reader.read_u8()?;
1650        match message_type {
1651            0 => {}
1652            TNS_MSG_TYPE_DESCRIBE_INFO => {
1653                let _describe_name = reader.read_bytes()?;
1654                let previous = std::mem::take(&mut result_columns);
1655                let mut described = QueryResult::default();
1656                parse_describe_info(&mut reader, capabilities, &mut described)?;
1657                result_columns = described.columns;
1658                for (index, column) in result_columns.iter_mut().enumerate() {
1659                    if let Some(prev) = previous.get(index) {
1660                        adjust_refetch_metadata(prev, column);
1661                    }
1662                }
1663            }
1664            TNS_MSG_TYPE_ROW_HEADER => {
1665                pending_bit_vector = parse_row_header(&mut reader)?;
1666            }
1667            TNS_MSG_TYPE_BIT_VECTOR => {
1668                pending_bit_vector = Some(parse_bit_vector(&mut reader, result_columns.len())?);
1669            }
1670            TNS_MSG_TYPE_ROW_DATA => {
1671                // Record where this row's column values begin, then advance the
1672                // reader past the row (skipping, not materializing).
1673                reader.limits().check_batch_rows(row_starts.len() + 1)?;
1674                row_starts.push(reader.position());
1675                let bit_vector = pending_bit_vector.take();
1676                any_bit_vector |= bit_vector.is_some();
1677                row_bit_vectors.push(bit_vector.clone());
1678                skip_row_data(
1679                    &mut reader,
1680                    &result_columns,
1681                    bit_vector.as_deref(),
1682                    fetch_long_status,
1683                    lob_decode_mode,
1684                )?;
1685            }
1686            TNS_MSG_TYPE_PARAMETER => {
1687                let _params = parse_query_return_parameters(&mut reader, false)?;
1688            }
1689            TNS_MSG_TYPE_STATUS => {
1690                let _call_status = reader.read_ub4()?;
1691                let _seq = reader.read_ub2()?;
1692            }
1693            TNS_MSG_TYPE_SERVER_SIDE_PIGGYBACK => {
1694                let _ = skip_server_side_piggyback(&mut reader)?;
1695            }
1696            TNS_MSG_TYPE_FLUSH_OUT_BINDS | TNS_MSG_TYPE_END_OF_RESPONSE => break,
1697            TNS_MSG_TYPE_TOKEN => {
1698                let _token = reader.read_ub8()?;
1699            }
1700            TNS_MSG_TYPE_IMPLICIT_RESULTSET => {
1701                // Mirror the owned parser's framing walk so the reader advances
1702                // past the implicit-resultset block identically (the borrowed
1703                // fetch API does not surface child cursors, but it must still
1704                // consume the bytes). reference messages/base.pyx
1705                // `_process_implicit_result`.
1706                let num_results = reader.read_ub4()?;
1707                reader
1708                    .limits()
1709                    .check_length_prefixed_elements(num_results as usize)?;
1710                for _ in 0..num_results {
1711                    let num_bytes = reader.read_u8()?;
1712                    reader.skip(usize::from(num_bytes))?;
1713                    let mut child = QueryResult::default();
1714                    parse_describe_info(&mut reader, capabilities, &mut child)?;
1715                    let _child_cursor_id = reader.read_ub2()?;
1716                }
1717            }
1718            TNS_MSG_TYPE_ERROR => {
1719                let info = parse_server_error_info(&mut reader, capabilities.ttc_field_version)?;
1720                if info.cursor_id != 0 {
1721                    cursor_id = u32::from(info.cursor_id);
1722                }
1723                row_count = info.row_count;
1724                if info.number == TNS_ERR_NO_DATA_FOUND && !result_columns.is_empty() {
1725                    more_rows = false;
1726                } else if info.number != 0 && info.number != TNS_ERR_ARRAY_DML_ERRORS {
1727                    return Err(ProtocolError::ServerErrorInfo(Box::new(
1728                        info.into_details(),
1729                    )));
1730                }
1731            }
1732            _ => {
1733                let position = reader.position().saturating_sub(1);
1734                if let Some(message) =
1735                    find_embedded_server_error(payload, capabilities.ttc_field_version, position)
1736                {
1737                    return Err(ProtocolError::ServerError(message));
1738                }
1739                return Err(ProtocolError::UnknownMessageType {
1740                    message_type,
1741                    position,
1742                });
1743            }
1744        }
1745    }
1746
1747    // If the batch never used duplicate-column compression, drop the per-row
1748    // bit-vector vector so iteration takes the zero-copy fast path (no owned
1749    // previous-row snapshotting).
1750    if !any_bit_vector {
1751        row_bit_vectors.clear();
1752    }
1753
1754    let batch = BorrowedRowBatch {
1755        buffer: payload.to_vec(),
1756        columns: result_columns,
1757        row_starts,
1758        row_bit_vectors,
1759        fetch_long_status,
1760        lob_decode_mode,
1761        // Seed the first compressed row's duplicate resolution from the caller's
1762        // prior-page row (only consulted when the batch uses bit vectors).
1763        previous_row_seed: any_bit_vector.then(|| {
1764            previous_row
1765                .map(<[Option<QueryValue>]>::to_vec)
1766                .unwrap_or_default()
1767        }),
1768    };
1769
1770    Ok(BorrowedFetchResult {
1771        batch,
1772        more_rows,
1773        cursor_id,
1774        row_count,
1775    })
1776}
1777
1778/// Advance `reader` past one ROW_DATA row **without materializing owned values**
1779/// — this is the offset-capture pass, so it must allocate nothing for the hot
1780/// scalar grid. Mirrors [`parse_row_data`]'s consumption exactly: duplicate
1781/// (bit-vector) columns carry no wire bytes and are skipped; the hot byte-field
1782/// scalar types are skipped with a zero-allocation length-prefixed skip; the
1783/// rare cold types (LOB / Vector / JSON / Cursor / Object / ROWID), whose wire
1784/// framing is non-trivial, fall back to [`parse_column_value`] (which may
1785/// allocate, but those are uncommon). `LONG`/`LONG RAW` status trailers are
1786/// consumed when `fetch_long_status`.
1787fn skip_row_data(
1788    reader: &mut TtcReader<'_>,
1789    columns: &[ColumnMetadata],
1790    bit_vector: Option<&[u8]>,
1791    fetch_long_status: bool,
1792    lob_decode_mode: LobDecodeMode,
1793) -> Result<()> {
1794    for (index, metadata) in columns.iter().enumerate() {
1795        if is_duplicate_column(bit_vector, index) {
1796            continue;
1797        }
1798        let consumed_byte_field = metadata.buffer_size != 0
1799            && matches!(
1800                metadata.ora_type_num,
1801                ORA_TYPE_NUM_VARCHAR
1802                    | ORA_TYPE_NUM_CHAR
1803                    | ORA_TYPE_NUM_LONG
1804                    | ORA_TYPE_NUM_RAW
1805                    | ORA_TYPE_NUM_LONG_RAW
1806                    | ORA_TYPE_NUM_NUMBER
1807                    | ORA_TYPE_NUM_BINARY_INTEGER
1808                    | ORA_TYPE_NUM_BINARY_DOUBLE
1809                    | ORA_TYPE_NUM_BINARY_FLOAT
1810                    | ORA_TYPE_NUM_BOOLEAN
1811                    | ORA_TYPE_NUM_INTERVAL_DS
1812                    | ORA_TYPE_NUM_INTERVAL_YM
1813                    | ORA_TYPE_NUM_DATE
1814                    | ORA_TYPE_NUM_TIMESTAMP
1815                    | ORA_TYPE_NUM_TIMESTAMP_LTZ
1816                    | ORA_TYPE_NUM_TIMESTAMP_TZ
1817            );
1818        if consumed_byte_field {
1819            reader.skip_bytes_field()?;
1820        } else {
1821            // Cold / non-byte-field type, or a zero-buffer-size column: defer to
1822            // the full owned decode purely to advance the reader correctly.
1823            let _ = parse_column_value_with_lob_mode(reader, metadata, lob_decode_mode)?;
1824        }
1825        if fetch_long_status
1826            && matches!(
1827                metadata.ora_type_num,
1828                ORA_TYPE_NUM_LONG | ORA_TYPE_NUM_LONG_RAW
1829            )
1830        {
1831            let _null_indicator = reader.read_sb4()?;
1832            let _return_code = reader.read_ub4()?;
1833        }
1834    }
1835    Ok(())
1836}
1837
1838pub(crate) fn encode_rowid_component(mut value: u32, size: usize, output: &mut String) {
1839    let mut encoded = vec![b'A'; size];
1840    for index in 0..size {
1841        let alphabet_index = usize::try_from(value & 0x3f).unwrap_or(0);
1842        encoded[size - index - 1] = TNS_BASE64_ALPHABET[alphabet_index];
1843        value >>= 6;
1844    }
1845    output.extend(encoded.into_iter().map(char::from));
1846}
1847
1848pub(crate) fn encode_physical_rowid(
1849    rba: u32,
1850    partition_id: u16,
1851    block_num: u32,
1852    slot_num: u16,
1853) -> String {
1854    let mut output = String::with_capacity(ORA_TYPE_SIZE_ROWID as usize);
1855    encode_rowid_component(rba, 6, &mut output);
1856    encode_rowid_component(u32::from(partition_id), 3, &mut output);
1857    encode_rowid_component(block_num, 6, &mut output);
1858    encode_rowid_component(u32::from(slot_num), 3, &mut output);
1859    output
1860}
1861
1862pub(crate) fn parse_rowid_value(reader: &mut TtcReader<'_>) -> Result<Option<String>> {
1863    let len = reader.read_u8()?;
1864    if len == 0 || len == crate::wire::TNS_NULL_LENGTH_INDICATOR {
1865        return Ok(None);
1866    }
1867    let rba = reader.read_ub4()?;
1868    let partition_id = reader.read_ub2()?;
1869    reader.skip(1)?;
1870    let block_num = reader.read_ub4()?;
1871    let slot_num = reader.read_ub2()?;
1872    Ok(Some(encode_physical_rowid(
1873        rba,
1874        partition_id,
1875        block_num,
1876        slot_num,
1877    )))
1878}
1879
1880pub(crate) fn encode_logical_urowid(bytes: &[u8]) -> String {
1881    let mut input_offset = 1;
1882    let mut input_len = bytes.len().saturating_sub(1);
1883    let mut output = String::with_capacity((bytes.len() / 3) * 4 + 4);
1884    output.push('*');
1885    while input_len > 0 {
1886        let mut pos = bytes[input_offset] >> 2;
1887        output.push(char::from(TNS_BASE64_ALPHABET[usize::from(pos)]));
1888
1889        pos = (bytes[input_offset] & 0x03) << 4;
1890        if input_len == 1 {
1891            output.push(char::from(TNS_BASE64_ALPHABET[usize::from(pos)]));
1892            break;
1893        }
1894        input_offset += 1;
1895        pos |= (bytes[input_offset] & 0xf0) >> 4;
1896        output.push(char::from(TNS_BASE64_ALPHABET[usize::from(pos)]));
1897
1898        pos = (bytes[input_offset] & 0x0f) << 2;
1899        if input_len == 2 {
1900            output.push(char::from(TNS_BASE64_ALPHABET[usize::from(pos)]));
1901            break;
1902        }
1903        input_offset += 1;
1904        pos |= (bytes[input_offset] & 0xc0) >> 6;
1905        output.push(char::from(TNS_BASE64_ALPHABET[usize::from(pos)]));
1906
1907        pos = bytes[input_offset] & 0x3f;
1908        output.push(char::from(TNS_BASE64_ALPHABET[usize::from(pos)]));
1909        input_offset += 1;
1910        input_len -= 3;
1911    }
1912    output
1913}
1914
1915pub(crate) fn parse_urowid_value(reader: &mut TtcReader<'_>) -> Result<Option<String>> {
1916    if reader.read_bytes()?.is_none() {
1917        return Ok(None);
1918    }
1919    let Some(bytes) = reader.read_bytes()? else {
1920        return Ok(None);
1921    };
1922    if bytes.len() < 13 {
1923        return Err(ProtocolError::TtcDecode("encoded UROWID too short"));
1924    }
1925    if bytes[0] == 1 {
1926        let rba = u32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]);
1927        let partition_id = u16::from_be_bytes([bytes[5], bytes[6]]);
1928        let block_num = u32::from_be_bytes([bytes[7], bytes[8], bytes[9], bytes[10]]);
1929        let slot_num = u16::from_be_bytes([bytes[11], bytes[12]]);
1930        Ok(Some(encode_physical_rowid(
1931            rba,
1932            partition_id,
1933            block_num,
1934            slot_num,
1935        )))
1936    } else {
1937        Ok(Some(encode_logical_urowid(&bytes)))
1938    }
1939}
1940
1941pub(crate) fn parse_lob_value(
1942    reader: &mut TtcReader<'_>,
1943    metadata: &ColumnMetadata,
1944    lob_decode_mode: LobDecodeMode,
1945) -> Result<Option<QueryValue>> {
1946    let num_bytes = reader.read_ub4()?;
1947    reader.limits().check_response_bytes(num_bytes as usize)?;
1948    if num_bytes == 0 {
1949        return Ok(None);
1950    }
1951    let (size, chunk_size) = if matches!(
1952        (lob_decode_mode, metadata.ora_type_num),
1953        (_, ORA_TYPE_NUM_BFILE) | (LobDecodeMode::PlainLocator, _)
1954    ) {
1955        (0, 0)
1956    } else {
1957        (reader.read_ub8()?, reader.read_ub4()?)
1958    };
1959    let Some(locator) = reader.read_bytes()? else {
1960        return Ok(None);
1961    };
1962    Ok(Some(QueryValue::Lob(Box::new(LobValue {
1963        ora_type_num: metadata.ora_type_num,
1964        csfrm: metadata.csfrm,
1965        locator,
1966        size,
1967        chunk_size,
1968    }))))
1969}
1970
1971#[cfg(test)]
1972mod lob_fetch_shape_tests {
1973    use super::*;
1974
1975    fn clob_column() -> ColumnMetadata {
1976        ColumnMetadata {
1977            name: "BODY".into(),
1978            ora_type_num: ORA_TYPE_NUM_CLOB,
1979            csfrm: CS_FORM_IMPLICIT,
1980            precision: 0,
1981            scale: 0,
1982            buffer_size: 4000,
1983            max_size: 4000,
1984            nulls_allowed: true,
1985            is_json: false,
1986            is_oson: false,
1987            object_schema: None,
1988            object_type_name: None,
1989            is_array: false,
1990            vector_dimensions: None,
1991            vector_format: 0,
1992            vector_flags: 0,
1993            domain_schema: None,
1994            domain_name: None,
1995            annotations: None,
1996        }
1997    }
1998
1999    fn blob_column() -> ColumnMetadata {
2000        ColumnMetadata {
2001            name: "IMAGE".into(),
2002            ora_type_num: ORA_TYPE_NUM_BLOB,
2003            csfrm: 0,
2004            precision: 0,
2005            scale: 0,
2006            buffer_size: 4000,
2007            max_size: 4000,
2008            nulls_allowed: true,
2009            is_json: false,
2010            is_oson: false,
2011            object_schema: None,
2012            object_type_name: None,
2013            is_array: false,
2014            vector_dimensions: None,
2015            vector_format: 0,
2016            vector_flags: 0,
2017            domain_schema: None,
2018            domain_name: None,
2019            annotations: None,
2020        }
2021    }
2022
2023    fn lob_row_payload(locator: &[u8], metadata: Option<(u64, u32)>) -> Vec<u8> {
2024        let mut writer = TtcWriter::new();
2025        writer.write_u8(TNS_MSG_TYPE_ROW_DATA);
2026        writer.write_ub4(u32::try_from(locator.len()).expect("locator length fits ub4"));
2027        if let Some((size, chunk_size)) = metadata {
2028            writer.write_ub8(size);
2029            writer.write_ub4(chunk_size);
2030        }
2031        writer
2032            .write_bytes_with_length(locator)
2033            .expect("synthetic locator length is encodable");
2034        writer.write_u8(TNS_MSG_TYPE_END_OF_RESPONSE);
2035        writer.into_bytes()
2036    }
2037
2038    fn write_define_lob_cell(writer: &mut TtcWriter, locator: &[u8], size: u64, chunk_size: u32) {
2039        writer.write_ub4(u32::try_from(locator.len()).expect("locator length fits ub4"));
2040        writer.write_ub8(size);
2041        writer.write_ub4(chunk_size);
2042        writer
2043            .write_bytes_with_length(locator)
2044            .expect("synthetic locator length is encodable");
2045    }
2046
2047    fn first_lob(result: &QueryResult) -> &LobValue {
2048        match &result.rows[0][0] {
2049            Some(QueryValue::Lob(lob)) => lob.as_ref(),
2050            other => panic!("expected LOB value, got {other:?}"),
2051        }
2052    }
2053
2054    #[test]
2055    fn column_metadata_discards_server_charset_id_and_keeps_csfrm_only() {
2056        let caps = ClientCapabilities {
2057            ttc_field_version: 0,
2058            max_string_size: 32_767,
2059            charset_id: 873,
2060        };
2061        let mut writer = TtcWriter::new();
2062        writer.write_u8(ORA_TYPE_NUM_VARCHAR);
2063        writer.write_u8(0); // flags
2064        writer.write_u8(0); // precision
2065        writer.write_u8(0); // scale
2066        writer.write_ub4(4000);
2067        writer.write_ub4(0); // max array elements
2068        writer.write_ub8(0); // cont flags
2069        writer
2070            .write_bytes_with_two_lengths(None)
2071            .expect("empty oid");
2072        writer.write_ub2(0); // version
2073        writer.write_ub2(0); // unusable server charset id: must not drive decoding
2074        writer.write_u8(CS_FORM_IMPLICIT);
2075        writer.write_ub4(4000);
2076        writer.write_u8(1); // nullable
2077        writer.write_u8(0); // flags
2078        writer
2079            .write_bytes_with_two_lengths(Some(b"TXT"))
2080            .expect("name");
2081        writer
2082            .write_bytes_with_two_lengths(None)
2083            .expect("object schema");
2084        writer
2085            .write_bytes_with_two_lengths(None)
2086            .expect("object type");
2087        writer.write_ub2(1); // column position
2088        writer.write_ub4(0); // uds flags
2089
2090        let bytes = writer.into_bytes();
2091        let mut reader = TtcReader::new(&bytes);
2092        let metadata = parse_column_metadata(&mut reader, caps).expect("metadata should parse");
2093        assert_eq!(metadata.name(), "TXT");
2094        assert_eq!(metadata.csfrm(), CS_FORM_IMPLICIT);
2095        assert_eq!(
2096            decode_text_value("ok".as_bytes(), metadata.csfrm()).expect("decode text"),
2097            "ok"
2098        );
2099    }
2100
2101    #[test]
2102    fn plain_fetch_lob_locator_omits_size_and_chunk_fields() {
2103        let locator: Vec<u8> = (0u8..114).collect();
2104        let payload = lob_row_payload(&locator, None);
2105        let columns = [clob_column()];
2106
2107        let result = parse_fetch_response_with_context(
2108            &payload,
2109            ClientCapabilities::default(),
2110            &columns,
2111            None,
2112        )
2113        .expect("plain fetch CLOB locator should decode");
2114
2115        let lob = first_lob(&result);
2116        assert_eq!(lob.locator, locator);
2117        assert_eq!(lob.size, 0);
2118        assert_eq!(lob.chunk_size, 0);
2119    }
2120
2121    #[test]
2122    fn define_fetch_lob_locator_includes_size_and_chunk_fields() {
2123        let locator: Vec<u8> = (0u8..114).collect();
2124        let payload = lob_row_payload(&locator, Some((23, 8060)));
2125        let columns = [clob_column()];
2126
2127        let result = parse_define_fetch_response_with_context_and_limits(
2128            &payload,
2129            ClientCapabilities::default(),
2130            &columns,
2131            None,
2132            ProtocolLimits::DEFAULT,
2133        )
2134        .expect("define fetch CLOB locator should decode");
2135
2136        let lob = first_lob(&result);
2137        assert_eq!(lob.locator, locator);
2138        assert_eq!(lob.size, 23);
2139        assert_eq!(lob.chunk_size, 8060);
2140    }
2141
2142    #[test]
2143    fn borrowed_define_fetch_lob_page_matches_owned_decode() {
2144        let columns = [clob_column(), blob_column()];
2145        let clob_locator_a: Vec<u8> = (0u8..114).collect();
2146        let blob_locator_a: Vec<u8> = (128u8..242).collect();
2147        let clob_locator_b: Vec<u8> = (32u8..146).collect();
2148        let blob_locator_b: Vec<u8> = (64u8..178).collect();
2149        let mut writer = TtcWriter::new();
2150
2151        writer.write_u8(TNS_MSG_TYPE_ROW_DATA);
2152        write_define_lob_cell(&mut writer, &clob_locator_a, 23, 8060);
2153        write_define_lob_cell(&mut writer, &blob_locator_a, 48, 4096);
2154        writer.write_u8(TNS_MSG_TYPE_ROW_DATA);
2155        write_define_lob_cell(&mut writer, &clob_locator_b, 31, 8060);
2156        write_define_lob_cell(&mut writer, &blob_locator_b, 96, 4096);
2157        writer.write_u8(TNS_MSG_TYPE_END_OF_RESPONSE);
2158        let payload = writer.into_bytes();
2159
2160        let owned = parse_define_fetch_response_with_context_and_limits(
2161            &payload,
2162            ClientCapabilities::default(),
2163            &columns,
2164            None,
2165            ProtocolLimits::DEFAULT,
2166        )
2167        .expect("owned define fetch CLOB/BLOB page should decode");
2168        let borrowed = parse_define_fetch_response_borrowed_with_limits(
2169            &payload,
2170            ClientCapabilities::default(),
2171            &columns,
2172            None,
2173            ProtocolLimits::DEFAULT,
2174        )
2175        .expect("borrowed define fetch CLOB/BLOB page should decode");
2176
2177        let mut borrowed_rows: Vec<Vec<Option<QueryValue>>> = Vec::new();
2178        borrowed
2179            .batch
2180            .for_each_row_ref(|row| {
2181                borrowed_rows.push(
2182                    row.iter()
2183                        .map(|cell| cell.map(|value| value.to_owned_value()))
2184                        .collect(),
2185                );
2186                Ok::<(), ProtocolError>(())
2187            })
2188            .expect("borrowed define fetch CLOB/BLOB page should iterate");
2189
2190        assert_eq!(owned.rows.len(), 2, "owned decode sees both rows");
2191        assert_eq!(
2192            borrowed.batch.row_count(),
2193            2,
2194            "borrowed decode sees both rows"
2195        );
2196        assert_eq!(
2197            borrowed_rows, owned.rows,
2198            "borrowed DefineMetadata LOB decode must match owned decode"
2199        );
2200    }
2201}
2202
2203/// Reads a VECTOR value (reference `ReadBuffer.read_vector` in `packet.pyx`).
2204/// VECTOR is sent as a fully-prefetched LOB: the image data precedes the
2205/// (discarded) LOB locator.
2206pub(crate) fn parse_vector_value(reader: &mut TtcReader<'_>) -> Result<Option<QueryValue>> {
2207    let num_bytes = reader.read_ub4()?;
2208    reader.limits().check_response_bytes(num_bytes as usize)?;
2209    if num_bytes == 0 {
2210        return Ok(None);
2211    }
2212    reader.read_ub8()?; // size (unused)
2213    reader.read_ub4()?; // chunk size (unused)
2214    let Some(data) = reader.read_bytes()? else {
2215        return Ok(None);
2216    };
2217    reader.read_bytes()?; // LOB locator (unused)
2218    if data.is_empty() {
2219        return Ok(None);
2220    }
2221    let vector = crate::vector::decode_vector_with_limits(&data, reader.limits())?;
2222    Ok(Some(QueryValue::Vector(Box::new(vector))))
2223}
2224
2225/// Parses a native JSON (`DB_TYPE_JSON`) column value. Like VECTOR, OSON is sent
2226/// as a fully-prefetched LOB: `num_bytes`, `size`, `chunk_size`, the OSON image,
2227/// then a (discarded) LOB locator (reference packet.pyx `read_oson`).
2228pub(crate) fn parse_json_value(reader: &mut TtcReader<'_>) -> Result<Option<QueryValue>> {
2229    let num_bytes = reader.read_ub4()?;
2230    reader.limits().check_response_bytes(num_bytes as usize)?;
2231    if num_bytes == 0 {
2232        return Ok(None);
2233    }
2234    reader.read_ub8()?; // size (unused)
2235    reader.read_ub4()?; // chunk size (unused)
2236    let Some(data) = reader.read_bytes()? else {
2237        return Ok(None);
2238    };
2239    reader.read_bytes()?; // LOB locator (unused)
2240    if data.is_empty() {
2241        return Ok(None);
2242    }
2243    let value = crate::oson::decode_oson_with_limits(&data, reader.limits())?;
2244    Ok(Some(QueryValue::Json(Box::new(value))))
2245}
2246
2247pub(crate) fn parse_object_value(
2248    reader: &mut TtcReader<'_>,
2249    metadata: &ColumnMetadata,
2250) -> Result<Option<QueryValue>> {
2251    let _toid = reader.read_bytes_with_length()?;
2252    let _oid = reader.read_bytes_with_length()?;
2253    let _snapshot = reader.read_bytes_with_length()?;
2254    let _version = reader.read_ub2()?;
2255    let num_bytes = reader.read_ub4()?;
2256    reader.limits().check_response_bytes(num_bytes as usize)?;
2257    reader.skip(2)?;
2258    if num_bytes == 0 {
2259        return Ok(None);
2260    }
2261    let Some(packed_data) = reader.read_bytes()? else {
2262        return Ok(None);
2263    };
2264    Ok(Some(QueryValue::Object(Box::new(ObjectValue {
2265        schema: metadata.object_schema.clone(),
2266        type_name: metadata.object_type_name.clone(),
2267        packed_data,
2268    }))))
2269}
2270
2271pub(crate) fn parse_cursor_value(reader: &mut TtcReader<'_>) -> Result<QueryValue> {
2272    reader.skip(1)?;
2273    let mut result = QueryResult::default();
2274    parse_describe_info(reader, ClientCapabilities::default(), &mut result)?;
2275    let cursor_id = u32::from(reader.read_ub2()?);
2276    Ok(QueryValue::Cursor(Box::new(CursorValue {
2277        columns: result.columns,
2278        cursor_id,
2279    })))
2280}
2281
2282pub(crate) struct QueryReturnParameters {
2283    pub row_counts: Option<Vec<u64>>,
2284    /// CQN registered-query id extracted from the registration-info block
2285    /// (reference base.pyx:1300-1309); `None` when no block was present.
2286    pub query_id: Option<u64>,
2287}
2288
2289pub(crate) fn parse_query_return_parameters(
2290    reader: &mut TtcReader<'_>,
2291    arraydmlrowcounts: bool,
2292) -> Result<QueryReturnParameters> {
2293    let num_params = reader.read_ub2()?;
2294    for _ in 0..num_params {
2295        let _value = reader.read_ub4()?;
2296    }
2297    let num_bytes = reader.read_ub2()?;
2298    if num_bytes > 0 {
2299        reader.skip(usize::from(num_bytes))?;
2300    }
2301    let num_pairs = reader.read_ub2()?;
2302    skip_keyword_value_pairs(reader, num_pairs)?;
2303    // registration info block: the trailing 8 bytes (msb at -4, lsb at -8) are
2304    // the CQN query id when a registration id was sent (reference base.pyx).
2305    let num_bytes = usize::from(reader.read_ub2()?);
2306    let mut query_id = None;
2307    if num_bytes > 0 {
2308        let block = reader.read_raw(num_bytes)?;
2309        if num_bytes >= 8 {
2310            let msb = u32::from_be_bytes([
2311                block[num_bytes - 4],
2312                block[num_bytes - 3],
2313                block[num_bytes - 2],
2314                block[num_bytes - 1],
2315            ]);
2316            let lsb = u32::from_be_bytes([
2317                block[num_bytes - 8],
2318                block[num_bytes - 7],
2319                block[num_bytes - 6],
2320                block[num_bytes - 5],
2321            ]);
2322            query_id = Some((u64::from(msb) << 32) | u64::from(lsb));
2323        }
2324    }
2325    if arraydmlrowcounts {
2326        // reference messages/base.pyx `_process_return_parameters` tail
2327        let num_rows = reader.read_ub4()?;
2328        reader.limits().check_batch_rows(num_rows as usize)?;
2329        // Each ub8 row count consumes at least one byte, so cap the reservation
2330        // by the remaining payload size (BoundedReader).
2331        let mut row_counts: Vec<u64> =
2332            reader.with_capacity_limited(num_rows as usize, 1, ProtocolLimits::check_batch_rows)?;
2333        for _ in 0..num_rows {
2334            row_counts.push(reader.read_ub8()?);
2335        }
2336        return Ok(QueryReturnParameters {
2337            row_counts: Some(row_counts),
2338            query_id,
2339        });
2340    }
2341    Ok(QueryReturnParameters {
2342        row_counts: None,
2343        query_id,
2344    })
2345}
2346
2347#[cfg(test)]
2348mod return_parameter_tests {
2349    use super::*;
2350
2351    #[test]
2352    fn registration_info_block_extracts_query_id_from_lsb_msb_tail() {
2353        let mut writer = TtcWriter::new();
2354        writer.write_ub2(0); // num params
2355        writer.write_ub2(0); // parameter bytes
2356        writer.write_ub2(0); // keyword/value pairs
2357        writer.write_ub2(8); // registration-info block bytes
2358        writer.write_raw(&[
2359            0x55, 0x66, 0x77, 0x88, // lsb
2360            0x11, 0x22, 0x33, 0x44, // msb
2361        ]);
2362        let payload = writer.into_bytes();
2363        let mut reader = TtcReader::new(&payload);
2364
2365        let params = parse_query_return_parameters(&mut reader, false).expect("return parameters");
2366
2367        assert_eq!(params.query_id, Some(0x1122_3344_5566_7788));
2368        assert_eq!(params.row_counts, None);
2369    }
2370}
2371
2372#[cfg(test)]
2373mod borrowed_fetch_tests {
2374    use super::*;
2375    use crate::thin::codecs::encode_number_text;
2376
2377    // Isomorphism proof for the `simd-decode` feature (bead rust-oracledb-63o):
2378    // `validate_utf8` must make the SAME accept/reject decision as the canonical
2379    // `core::str::from_utf8`, AND return the same `&str` on accept, for every
2380    // input — whether or not the SIMD validator is compiled in. This guards the
2381    // hot text path against any divergence in UTF-8 grammar handling.
2382    #[test]
2383    fn validate_utf8_matches_core_accept_reject() {
2384        let cases: &[&[u8]] = &[
2385            b"",
2386            b"a",
2387            b"hello world",
2388            "VARCHAR2 cell".as_bytes(),
2389            "中文 mixed \u{1f600}".as_bytes(), // CJK + emoji (4-byte)
2390            "naïve café".as_bytes(),
2391            &[0x80],                   // lone continuation byte -> reject
2392            &[0xC0, 0x80],             // overlong NUL -> reject
2393            &[0xED, 0xA0, 0x80],       // UTF-16 surrogate -> reject
2394            &[0xF4, 0x90, 0x80, 0x80], // > U+10FFFF -> reject
2395            &[0xFF],                   // invalid lead -> reject
2396            &[0xE2, 0x82],             // truncated 3-byte -> reject
2397        ];
2398        for &bytes in cases {
2399            let core = core::str::from_utf8(bytes);
2400            let ours = validate_utf8(bytes);
2401            assert_eq!(
2402                core.is_ok(),
2403                ours.is_ok(),
2404                "accept/reject diverged for {bytes:02x?}"
2405            );
2406            if let (Ok(a), Ok(b)) = (core, ours) {
2407                assert_eq!(a, b, "accepted text diverged for {bytes:02x?}");
2408            }
2409        }
2410    }
2411
2412    // Build a synthetic column metadata for a scalar type.
2413    fn col(name: &str, ora_type_num: u8, csfrm: u8, buffer_size: u32) -> ColumnMetadata {
2414        ColumnMetadata {
2415            name: name.to_string(),
2416            ora_type_num,
2417            csfrm,
2418            buffer_size,
2419            ..ColumnMetadata::default()
2420        }
2421    }
2422
2423    // Encode one row of [Text, Number, Raw, NULL-text] as the server would frame
2424    // the column values (each a `write_bytes_with_length` run that `read_bytes`
2425    // / `read_bytes_borrowed` consume identically), and return the byte offset
2426    // where the row's column values begin.
2427    fn encode_mixed_row(writer: &mut TtcWriter, text: &str, number: &str, raw: &[u8]) {
2428        writer.write_bytes_with_length(text.as_bytes()).unwrap();
2429        let num = encode_number_text(number).unwrap();
2430        writer.write_bytes_with_length(&num).unwrap();
2431        writer.write_bytes_with_length(raw).unwrap();
2432        writer.write_u8(0); // NULL column (length byte 0)
2433    }
2434
2435    // The borrowed batch decode must yield, for every cell, a value whose
2436    // `to_owned_value()` is bit-for-bit the owned-path `QueryValue`, across a
2437    // mixed Text/Number/Raw/NULL row. And the Text/Raw cells must genuinely
2438    // borrow the batch buffer (zero-copy), not a fresh allocation.
2439    #[test]
2440    fn borrowed_batch_matches_owned_path_for_mixed_row() {
2441        let columns = vec![
2442            col("T", ORA_TYPE_NUM_VARCHAR, CS_FORM_IMPLICIT, 4000),
2443            col("N", ORA_TYPE_NUM_NUMBER, CS_FORM_IMPLICIT, 22),
2444            col("R", ORA_TYPE_NUM_RAW, CS_FORM_IMPLICIT, 2000),
2445            col("Z", ORA_TYPE_NUM_VARCHAR, CS_FORM_IMPLICIT, 4000),
2446        ];
2447
2448        let mut writer = TtcWriter::new();
2449        encode_mixed_row(
2450            &mut writer,
2451            "héllo world",
2452            "-12.5",
2453            &[0xDE, 0xAD, 0xBE, 0xEF],
2454        );
2455        encode_mixed_row(&mut writer, "second", "42", &[0x01]);
2456        let buffer = writer.into_bytes();
2457        let row_starts = vec![0, {
2458            // Find the second row's start by replaying the first row's consumption.
2459            let mut reader = TtcReader::new(&buffer);
2460            for c in &columns {
2461                let _ = parse_column_value(&mut reader, c).unwrap();
2462            }
2463            reader.position()
2464        }];
2465
2466        // Owned path: decode both rows the existing way for the golden values.
2467        let owned_rows: Vec<Vec<Option<QueryValue>>> = row_starts
2468            .iter()
2469            .map(|&start| {
2470                let mut reader = TtcReader::new(&buffer[start..]);
2471                columns
2472                    .iter()
2473                    .map(|c| parse_column_value(&mut reader, c).unwrap())
2474                    .collect()
2475            })
2476            .collect();
2477
2478        // Borrowed path: decode through the batch, collecting owned copies and
2479        // proving the scalar cells borrow the buffer.
2480        let batch = BorrowedRowBatch::new(buffer.clone(), columns.clone(), row_starts);
2481        let buf_ptr_range = batch.buffer_ptr_range();
2482
2483        let mut seen_rows = 0usize;
2484        let mut borrowed_owned: Vec<Vec<Option<QueryValue>>> = Vec::new();
2485        batch
2486            .for_each_row_ref(|row| {
2487                seen_rows += 1;
2488                // Text cell borrows the buffer.
2489                if let Some(QueryValueRef::Text(t)) = row[0] {
2490                    let p = t.as_ptr() as usize;
2491                    assert!(
2492                        buf_ptr_range.contains(&p),
2493                        "Text cell must borrow the batch buffer (zero-copy)"
2494                    );
2495                }
2496                // Raw cell borrows the buffer.
2497                if let Some(QueryValueRef::Raw(r)) = row[2] {
2498                    let p = r.as_ptr() as usize;
2499                    assert!(
2500                        buf_ptr_range.contains(&p),
2501                        "Raw cell must borrow the batch buffer (zero-copy)"
2502                    );
2503                }
2504                borrowed_owned.push(
2505                    row.iter()
2506                        .map(|cell| cell.map(|v| v.to_owned_value()))
2507                        .collect(),
2508                );
2509                Ok::<(), ProtocolError>(())
2510            })
2511            .unwrap();
2512
2513        assert_eq!(seen_rows, 2, "batch yields both rows");
2514        assert_eq!(
2515            borrowed_owned, owned_rows,
2516            "borrowed cells to_owned() must equal the owned-path values"
2517        );
2518    }
2519
2520    #[test]
2521    fn borrowed_number_to_owned_matches_owned_for_trailing_zero_number() {
2522        let column = col("N", ORA_TYPE_NUM_NUMBER, CS_FORM_IMPLICIT, 22);
2523        let number = encode_number_text("1000").expect("encode trailing-zero number");
2524        let mut writer = TtcWriter::new();
2525        writer
2526            .write_bytes_with_length(&number)
2527            .expect("write framed number");
2528        let buffer = writer.into_bytes();
2529
2530        let mut owned_reader = TtcReader::new(&buffer);
2531        let owned = parse_column_value(&mut owned_reader, &column)
2532            .expect("owned decode")
2533            .expect("owned number should be non-null");
2534
2535        let batch = BorrowedRowBatch::new(buffer, vec![column], vec![0]);
2536        let mut borrowed_owned = Vec::new();
2537        batch
2538            .for_each_row_ref(|row| {
2539                borrowed_owned.push(
2540                    row[0]
2541                        .expect("borrowed number should be non-null")
2542                        .to_owned_value(),
2543                );
2544                Ok::<(), ProtocolError>(())
2545            })
2546            .expect("borrowed decode");
2547        let borrowed = borrowed_owned
2548            .pop()
2549            .expect("borrowed decode should yield one row");
2550
2551        assert_eq!(
2552            borrowed.as_number_text().as_deref(),
2553            owned.as_number_text().as_deref(),
2554            "borrowed and owned paths must expose identical canonical NUMBER text"
2555        );
2556        assert_eq!(
2557            borrowed, owned,
2558            "borrowed to_owned_value should materialize the same trailing-zero NUMBER"
2559        );
2560    }
2561
2562    #[test]
2563    fn describe_size_zero_urowid_decodes_and_preserves_following_column_alignment() {
2564        let columns = vec![
2565            col("RID", ORA_TYPE_NUM_UROWID, CS_FORM_IMPLICIT, 0),
2566            col("NEXT", ORA_TYPE_NUM_VARCHAR, CS_FORM_IMPLICIT, 4000),
2567        ];
2568        let rba: u32 = 0x0102_0304;
2569        let partition_id: u16 = 0x0506;
2570        let block_num: u32 = 0x0708_090a;
2571        let slot_num: u16 = 0x0b0c;
2572        let mut encoded_urowid = Vec::new();
2573        encoded_urowid.push(1);
2574        encoded_urowid.extend_from_slice(&rba.to_be_bytes());
2575        encoded_urowid.extend_from_slice(&partition_id.to_be_bytes());
2576        encoded_urowid.extend_from_slice(&block_num.to_be_bytes());
2577        encoded_urowid.extend_from_slice(&slot_num.to_be_bytes());
2578        let expected_rowid = encode_physical_rowid(rba, partition_id, block_num, slot_num);
2579
2580        let mut writer = TtcWriter::new();
2581        writer
2582            .write_bytes_with_length(&[1])
2583            .expect("write UROWID null probe field");
2584        writer
2585            .write_bytes_with_length(&encoded_urowid)
2586            .expect("write encoded UROWID");
2587        writer
2588            .write_bytes_with_length(b"after")
2589            .expect("write following text column");
2590        let buffer = writer.into_bytes();
2591
2592        let mut owned_reader = TtcReader::new(&buffer);
2593        let owned = columns
2594            .iter()
2595            .map(|column| parse_column_value(&mut owned_reader, column).expect("owned decode"))
2596            .collect::<Vec<_>>();
2597        assert_eq!(
2598            owned[0].as_ref().and_then(QueryValue::as_rowid),
2599            Some(expected_rowid.as_str()),
2600            "owned decode must not NULL a describe-size-0 UROWID"
2601        );
2602        assert_eq!(
2603            owned[1].as_ref().and_then(QueryValue::as_text),
2604            Some("after"),
2605            "owned decode must consume UROWID bytes before the following column"
2606        );
2607
2608        let batch = BorrowedRowBatch::new(buffer, columns, vec![0]);
2609        let mut borrowed_rows = Vec::new();
2610        batch
2611            .for_each_row_ref(|row| {
2612                borrowed_rows.push(
2613                    row.iter()
2614                        .map(|cell| cell.map(|value| value.to_owned_value()))
2615                        .collect::<Vec<_>>(),
2616                );
2617                Ok::<(), ProtocolError>(())
2618            })
2619            .expect("borrowed decode");
2620        assert_eq!(borrowed_rows, vec![owned]);
2621    }
2622
2623    // The borrowed response parser walks the *same* message framing as the owned
2624    // `parse_fetch_response_with_context` (ROW_HEADER / BIT_VECTOR / ROW_DATA /
2625    // END_OF_RESPONSE), but instead of building owned rows it captures each
2626    // row's byte offset and hands back a `BorrowedRowBatch`. Decoding that batch
2627    // must reproduce exactly what the owned fetch path produced — duplicate
2628    // columns (bit vector) and all. Fixture is the same one the owned
2629    // `fetch_response_decodes_rows_with_previous_cursor_metadata` test uses.
2630    #[test]
2631    fn borrowed_response_parse_matches_owned_fetch_path() {
2632        use hex::FromHex;
2633        let payload = Vec::from_hex("06020101000205dc0001010101000702c1041d")
2634            .expect("fixture response should be valid hex");
2635        let columns = vec![
2636            col("INTCOL", ORA_TYPE_NUM_NUMBER, CS_FORM_IMPLICIT, 22),
2637            col("NUMBERCOL", ORA_TYPE_NUM_NUMBER, CS_FORM_IMPLICIT, 22),
2638        ];
2639        let previous_row = vec![
2640            Some(QueryValue::number_from_text("2", true)),
2641            Some(QueryValue::number_from_text("0.5", false)),
2642        ];
2643
2644        // Owned golden.
2645        let owned = parse_query_response_with_context(
2646            &payload,
2647            ClientCapabilities::default(),
2648            &columns,
2649            Some(&previous_row),
2650        )
2651        .expect("owned fetch decode");
2652
2653        // Borrowed parse.
2654        let borrowed = parse_query_response_borrowed(
2655            &payload,
2656            ClientCapabilities::default(),
2657            &columns,
2658            Some(&previous_row),
2659        )
2660        .expect("borrowed fetch decode");
2661
2662        assert_eq!(borrowed.more_rows, owned.more_rows);
2663        assert_eq!(borrowed.cursor_id, owned.cursor_id);
2664        assert_eq!(borrowed.batch.row_count(), owned.rows.len());
2665
2666        let mut borrowed_owned: Vec<Vec<Option<QueryValue>>> = Vec::new();
2667        borrowed
2668            .batch
2669            .for_each_row_ref(|row| {
2670                borrowed_owned.push(
2671                    row.iter()
2672                        .map(|cell| cell.map(|v| v.to_owned_value()))
2673                        .collect(),
2674                );
2675                Ok::<(), ProtocolError>(())
2676            })
2677            .expect("iterate borrowed rows");
2678
2679        assert_eq!(
2680            borrowed_owned, owned.rows,
2681            "borrowed batch must reproduce the owned fetch rows (incl. duplicate columns)"
2682        );
2683    }
2684}
2685
2686#[cfg(test)]
2687mod out_bind_boolean_regression_tests {
2688    use super::*;
2689
2690    fn boolean_column(is_array: bool) -> ColumnMetadata {
2691        ColumnMetadata {
2692            name: "B".to_string(),
2693            ora_type_num: ORA_TYPE_NUM_BOOLEAN,
2694            is_array,
2695            ..ColumnMetadata::default()
2696        }
2697    }
2698
2699    fn boolean_value_with_negative_actual_bytes() -> Vec<u8> {
2700        let mut writer = TtcWriter::new();
2701        writer
2702            .write_bytes_with_length(&[1])
2703            .expect("write present boolean value");
2704        writer.write_sb4(-1);
2705        writer.into_bytes()
2706    }
2707
2708    #[test]
2709    fn scalar_boolean_out_bind_negative_actual_bytes_decodes_null() {
2710        let bind_columns = [boolean_column(false)];
2711        let out_bind_indexes = [0usize];
2712        let payload = boolean_value_with_negative_actual_bytes();
2713        let mut reader = TtcReader::new(&payload);
2714        let mut result = QueryResult::default();
2715
2716        parse_out_bind_row_data(&mut reader, &mut result, &bind_columns, &out_bind_indexes)
2717            .expect("parse scalar BOOLEAN OUT bind");
2718
2719        assert_eq!(result.out_values, vec![(0, None)]);
2720    }
2721
2722    #[test]
2723    fn array_boolean_out_bind_negative_actual_bytes_decodes_null_element() {
2724        let bind_columns = [boolean_column(true)];
2725        let out_bind_indexes = [0usize];
2726        let mut writer = TtcWriter::new();
2727        writer.write_ub4(1);
2728        writer
2729            .write_bytes_with_length(&[1])
2730            .expect("write present boolean array element");
2731        writer.write_sb4(-1);
2732        let payload = writer.into_bytes();
2733        let mut reader = TtcReader::new(&payload);
2734        let mut result = QueryResult::default();
2735
2736        parse_out_bind_row_data(&mut reader, &mut result, &bind_columns, &out_bind_indexes)
2737            .expect("parse array BOOLEAN OUT bind");
2738
2739        assert_eq!(
2740            result.out_values,
2741            vec![(0, Some(QueryValue::Array(vec![None])))]
2742        );
2743    }
2744
2745    #[test]
2746    fn returning_boolean_negative_actual_bytes_decodes_null() {
2747        let bind_columns = [boolean_column(false)];
2748        let output_bind_indexes = [0usize];
2749        let mut writer = TtcWriter::new();
2750        writer.write_ub4(1);
2751        writer
2752            .write_bytes_with_length(&[1])
2753            .expect("write present returning boolean value");
2754        writer.write_sb4(-1);
2755        let payload = writer.into_bytes();
2756        let mut reader = TtcReader::new(&payload);
2757        let mut result = QueryResult::default();
2758
2759        parse_returning_row_data(
2760            &mut reader,
2761            &mut result,
2762            &bind_columns,
2763            &output_bind_indexes,
2764        )
2765        .expect("parse BOOLEAN RETURNING value");
2766
2767        assert_eq!(result.return_values, vec![(0, vec![None])]);
2768    }
2769}
2770
2771#[cfg(test)]
2772mod fuzz_regression_tests {
2773    use super::*;
2774
2775    // Regression (w6-fuzz, query_response target): a TNS_MSG_TYPE_IMPLICIT_RESULTSET
2776    // message (27) whose ub4 result count was ~620M made the dispatch loop
2777    // `Vec::with_capacity` several gigabytes of `QueryValue::Cursor` before the
2778    // truncated read failed, tripping libFuzzer's OOM detector. The parser must
2779    // now fail closed (truncated payload) without the giant allocation.
2780    #[test]
2781    fn fuzz_regression_implicit_resultset_oom() {
2782        // payload: type=27, ub4 length byte 4, value 0x25000000 (~620M), then EOF
2783        let payload = [27u8, 4, 37, 0, 0, 0];
2784        let err = parse_query_response(&payload, ClientCapabilities::default())
2785            .expect_err("oversized implicit-resultset count must fail closed");
2786        assert!(
2787            matches!(
2788                err,
2789                ProtocolError::TtcDecode(_) | ProtocolError::ResourceLimit { .. }
2790            ),
2791            "expected fail-closed protocol error, got {err:?}"
2792        );
2793    }
2794
2795    // BoundedReader invariant (l2p), query-columns family: a DESCRIBE_INFO
2796    // message (16) declaring a huge num_columns (ub4 ~620M) with no column
2797    // metadata bytes following must fail closed, not pre-allocate one
2798    // ColumnMetadata per declared column. parse_describe_info grows the column
2799    // Vec via push (no speculative with_capacity), and the first
2800    // parse_column_metadata read past the end errors.
2801    #[test]
2802    fn describe_info_oversized_column_count_fails_closed_not_oom() {
2803        // type=16 DESCRIBE_INFO; describe_name read_bytes len byte 0 (null);
2804        // max_row_size ub4 = 0; num_columns ub4 (len byte 4) = 0x25000000
2805        // (~620M); then EOF before the skip(1)/column records.
2806        let payload = [16u8, 0, 0, 4, 0x25, 0x00, 0x00, 0x00];
2807        let err = parse_query_response(&payload, ClientCapabilities::default())
2808            .expect_err("oversized column count must fail closed");
2809        assert!(
2810            matches!(
2811                err,
2812                ProtocolError::TtcDecode(_) | ProtocolError::ResourceLimit { .. }
2813            ),
2814            "expected fail-closed protocol error, got {err:?}"
2815        );
2816    }
2817
2818    #[test]
2819    fn describe_info_respects_protocol_column_limit() {
2820        // type=16 DESCRIBE_INFO; describe_name null; max_row_size=0;
2821        // num_columns=2. A max_columns=1 policy should fail before any column
2822        // metadata allocation/parsing.
2823        let payload = [TNS_MSG_TYPE_DESCRIBE_INFO, 0, 0, 1, 2];
2824        let limits = ProtocolLimits {
2825            max_columns: 1,
2826            ..ProtocolLimits::DEFAULT
2827        };
2828        let err = parse_query_response_with_limits(&payload, ClientCapabilities::default(), limits)
2829            .expect_err("column count above policy must fail");
2830        assert!(
2831            matches!(
2832                err,
2833                ProtocolError::ResourceLimit {
2834                    limit: "columns",
2835                    observed: 2,
2836                    maximum: 1,
2837                }
2838            ),
2839            "expected column ResourceLimit, got {err:?}"
2840        );
2841    }
2842
2843    // BoundedReader invariant (l2p), out-bind array family: an array OUT bind
2844    // whose ub4 num_elements is enormous (~620M) but carries no element bytes
2845    // must fail closed via with_capacity_bounded + the per-element read, not
2846    // reserve gigabytes of Option<QueryValue>.
2847    #[test]
2848    fn out_bind_array_oversized_element_count_fails_closed_not_oom() {
2849        let metadata = ColumnMetadata {
2850            name: "ARR".to_string(),
2851            ora_type_num: ORA_TYPE_NUM_NUMBER,
2852            is_array: true,
2853            ..ColumnMetadata::default()
2854        };
2855        let bind_columns = [metadata];
2856        let out_bind_indexes = [0usize];
2857        // ub4 num_elements: len byte 4, value 0x25000000, then no elements.
2858        let payload = [4u8, 0x25, 0x00, 0x00, 0x00];
2859        let mut reader = TtcReader::new(&payload);
2860        let mut result = QueryResult::default();
2861        let err =
2862            parse_out_bind_row_data(&mut reader, &mut result, &bind_columns, &out_bind_indexes)
2863                .expect_err("oversized array OUT bind count must fail closed");
2864        assert!(
2865            matches!(
2866                err,
2867                ProtocolError::TtcDecode(_) | ProtocolError::ResourceLimit { .. }
2868            ),
2869            "expected fail-closed protocol error, got {err:?}"
2870        );
2871    }
2872
2873    // ---- describe-read version-gate boundary tests -------------------------
2874    //
2875    // Reference messages/base.pyx `_process_column_metadata` gates four fields
2876    // on the negotiated ttc field version:
2877    //   :346  >= 12.2        ub4 oaccolid                (below the 18c floor)
2878    //   :358  >= 23.1        domain schema + name
2879    //   :361  >= 23.1 ext 3  annotations block
2880    //   :376  >= 23.4        vector dimensions/format/flags
2881    // parse_column_metadata mirrors each. The 12.2 boundary is below our live
2882    // floor (18c field version 11), so no live server exercises the pre-12.2
2883    // read path; this offline test pins every boundary by proving the parser
2884    // consumes *exactly* the bytes for its field version and misaligns (fails
2885    // closed, or leaves the wire unconsumed) when parsed one version off.
2886
2887    fn describe_caps(fv: u8) -> ClientCapabilities {
2888        ClientCapabilities {
2889            ttc_field_version: fv,
2890            max_string_size: 32_767,
2891            charset_id: 873,
2892        }
2893    }
2894
2895    /// One column-metadata record whose optional fields are present iff the
2896    /// field version gates them in — i.e. exactly what a server at `fv` sends.
2897    fn describe_column_bytes(fv: u8) -> Vec<u8> {
2898        let mut w = TtcWriter::new();
2899        w.write_u8(ORA_TYPE_NUM_VARCHAR);
2900        w.write_u8(0); // flags
2901        w.write_u8(0); // precision
2902        w.write_u8(0); // scale
2903        w.write_ub4(4000); // buffer size
2904        w.write_ub4(0); // max array elements
2905        w.write_ub8(0); // cont flags
2906        w.write_bytes_with_two_lengths(None).expect("oid");
2907        w.write_ub2(0); // version
2908        w.write_ub2(0); // server charset id (ignored)
2909        w.write_u8(CS_FORM_IMPLICIT);
2910        w.write_ub4(4000); // max size
2911        if fv >= TNS_CCAP_FIELD_VERSION_12_2 {
2912            w.write_ub4(0x1122_3344); // oaccolid (5 wire bytes when present)
2913        }
2914        w.write_u8(1); // nullable
2915        w.write_u8(0); // flags
2916        w.write_bytes_with_two_lengths(Some(b"TXT")).expect("name");
2917        w.write_bytes_with_two_lengths(None).expect("object schema");
2918        w.write_bytes_with_two_lengths(None).expect("object type");
2919        w.write_ub2(1); // column position
2920        w.write_ub4(0); // uds flags
2921        if fv >= TNS_CCAP_FIELD_VERSION_23_1 {
2922            w.write_bytes_with_two_lengths(None).expect("domain schema");
2923            w.write_bytes_with_two_lengths(None).expect("domain name");
2924        }
2925        if fv >= TNS_CCAP_FIELD_VERSION_23_1_EXT_3 {
2926            w.write_ub4(0); // num annotations (0 => no sub-block)
2927        }
2928        if fv >= TNS_CCAP_FIELD_VERSION_23_4 {
2929            w.write_ub4(2); // vector dimensions
2930            w.write_u8(0); // format
2931            w.write_u8(0); // flags
2932        }
2933        w.into_bytes()
2934    }
2935
2936    /// `Some((column_name, remaining_bytes))` after a successful parse, else
2937    /// `None` (failed closed). A version-matched parse decodes the name and
2938    /// consumes the record exactly => `Some(("TXT", 0))`; any misaligned read
2939    /// diverges (different name, leftover bytes, or a hard failure).
2940    fn parse_outcome(bytes: &[u8], fv: u8) -> Option<(String, usize)> {
2941        let mut reader = TtcReader::new(bytes);
2942        parse_column_metadata(&mut reader, describe_caps(fv))
2943            .ok()
2944            .map(|meta| (meta.name().to_string(), reader.remaining()))
2945    }
2946
2947    #[test]
2948    fn describe_column_metadata_gates_fields_on_field_version() {
2949        // Each (lo, hi) pair straddles exactly one gate; the other three gates
2950        // are on the same side of the boundary for both, so only one field moves.
2951        let boundaries = [
2952            (TNS_CCAP_FIELD_VERSION_12_2 - 1, TNS_CCAP_FIELD_VERSION_12_2), // oaccolid
2953            (TNS_CCAP_FIELD_VERSION_23_1 - 1, TNS_CCAP_FIELD_VERSION_23_1), // domain
2954            (
2955                TNS_CCAP_FIELD_VERSION_23_1_EXT_3 - 1,
2956                TNS_CCAP_FIELD_VERSION_23_1_EXT_3,
2957            ), // annotations
2958            (TNS_CCAP_FIELD_VERSION_23_4 - 1, TNS_CCAP_FIELD_VERSION_23_4), // vector
2959        ];
2960        for (lo, hi) in boundaries {
2961            let lo_bytes = describe_column_bytes(lo);
2962            let hi_bytes = describe_column_bytes(hi);
2963            assert!(
2964                hi_bytes.len() > lo_bytes.len(),
2965                "fv {hi}: the gated field must add bytes vs fv {lo}"
2966            );
2967
2968            // Version-matched parses decode the name and consume exactly.
2969            let matched = Some(("TXT".to_string(), 0));
2970            assert_eq!(parse_outcome(&lo_bytes, lo), matched, "fv {lo} matched");
2971            assert_eq!(parse_outcome(&hi_bytes, hi), matched, "fv {hi} matched");
2972
2973            // Parsed one version off, the record misaligns: reading `hi` bytes
2974            // as `lo` skips the gated field (leftover bytes remain), and reading
2975            // `lo` bytes as `hi` over-reads (fails closed). Neither matches.
2976            assert_ne!(
2977                parse_outcome(&hi_bytes, lo),
2978                matched,
2979                "fv {hi} bytes read as fv {lo} must not consume cleanly"
2980            );
2981            assert_ne!(
2982                parse_outcome(&lo_bytes, hi),
2983                matched,
2984                "fv {lo} bytes read as fv {hi} must not consume cleanly"
2985            );
2986        }
2987    }
2988}