Skip to main content

opentfraw/
reader.rs

1use std::collections::HashMap;
2use std::io::{Read, Seek, SeekFrom};
3
4use crate::error::{Error, Result};
5use crate::error_log::ErrorEntry;
6use crate::generic_data::{GenericDataHeader, GenericRecord, GenericValue};
7use crate::header::FileHeader;
8use crate::raw_file_info::RawFileInfo;
9use crate::run_header::RunHeader;
10use crate::scan_data::{
11    read_flat_peaks, read_scan_srm_v66, search_v63_transition, Peak, ScanDataPacket,
12};
13use crate::scan_event::{ScanEvent, ScanEventPreamble};
14use crate::scan_index::ScanIndexEntry;
15use crate::seq_row::SeqRow;
16
17/// Low-level binary reading helpers.
18pub(crate) struct BinaryReader<R> {
19    inner: R,
20    pos: u64,
21    /// Cached total stream length, populated on first call to [`Self::length`]
22    /// or [`Self::remaining`]. The underlying stream is never written to
23    /// while parsing, so this is safe to cache for the reader's lifetime and
24    /// avoids repeated end-of-stream seeks in allocation-size checks that can
25    /// run once per record in a large loop.
26    total_len: Option<u64>,
27}
28
29impl<R: Read + Seek> BinaryReader<R> {
30    pub fn new(inner: R) -> Self {
31        Self {
32            inner,
33            pos: 0,
34            total_len: None,
35        }
36    }
37
38    pub fn into_inner(self) -> R {
39        self.inner
40    }
41
42    #[allow(dead_code)]
43    pub(crate) fn position(&self) -> u64 {
44        self.pos
45    }
46
47    pub fn seek_to(&mut self, offset: u64) -> Result<()> {
48        self.inner.seek(SeekFrom::Start(offset))?;
49        self.pos = offset;
50        Ok(())
51    }
52
53    pub fn read_bytes(&mut self, n: usize) -> Result<Vec<u8>> {
54        // Guard against a crafted/corrupt file declaring an implausible
55        // length-prefix: verify the bytes could actually exist in the
56        // remaining input before allocating a buffer for them.
57        self.check_count(n as u64, 1)?;
58        let mut buf = vec![0u8; n];
59        self.inner.read_exact(&mut buf).map_err(|e| {
60            if e.kind() == std::io::ErrorKind::UnexpectedEof {
61                Error::UnexpectedEof {
62                    offset: self.pos,
63                    needed: n,
64                }
65            } else {
66                Error::Io(e)
67            }
68        })?;
69        self.pos += n as u64;
70        Ok(buf)
71    }
72
73    pub fn read_bytes_into(&mut self, buf: &mut [u8]) -> Result<()> {
74        let n = buf.len();
75        self.inner.read_exact(buf).map_err(|e| {
76            if e.kind() == std::io::ErrorKind::UnexpectedEof {
77                Error::UnexpectedEof {
78                    offset: self.pos,
79                    needed: n,
80                }
81            } else {
82                Error::Io(e)
83            }
84        })?;
85        self.pos += n as u64;
86        Ok(())
87    }
88
89    pub fn skip(&mut self, n: usize) -> Result<()> {
90        self.inner.seek(SeekFrom::Current(n as i64))?;
91        self.pos += n as u64;
92        Ok(())
93    }
94
95    pub fn length(&mut self) -> Result<u64> {
96        if let Some(len) = self.total_len {
97            return Ok(len);
98        }
99        // Query and restore the *true* underlying position (not `self.pos`):
100        // some callers construct a fresh `BinaryReader` over a source that
101        // was already seeked to a nonzero offset, so `self.pos` (which starts
102        // at 0 for every new reader) would not agree with where the
103        // underlying stream actually is.
104        let cur = self.inner.stream_position()?;
105        let end = self.inner.seek(SeekFrom::End(0))?;
106        self.inner.seek(SeekFrom::Start(cur))?;
107        self.total_len = Some(end);
108        Ok(end)
109    }
110
111    /// Bytes remaining between the stream's true current position and its end.
112    pub(crate) fn remaining(&mut self) -> Result<u64> {
113        let cur = self.inner.stream_position()?;
114        let end = self.length()?;
115        Ok(end.saturating_sub(cur))
116    }
117
118    /// Verify that `count` items of at least `min_item_bytes` bytes each
119    /// could plausibly still be read from the stream, before any allocation
120    /// proportional to `count` is attempted.
121    ///
122    /// Crafted or corrupt RAW files can declare implausible counts (a scan
123    /// count derived from a bogus header field, a peak count with no
124    /// relation to the file's actual size, etc). Without this check, code
125    /// like `Vec::with_capacity(count)` would allocate memory proportional
126    /// to an attacker-controlled value rather than to what the input could
127    /// actually contain - a memory-exhaustion vector reachable before a
128    /// single byte of the declared payload is read.
129    pub(crate) fn check_count(&mut self, count: u64, min_item_bytes: u64) -> Result<()> {
130        let remaining = self.remaining()?;
131        let needed = count.saturating_mul(min_item_bytes.max(1));
132        if needed > remaining {
133            return Err(Error::AllocationTooLarge {
134                offset: self.pos,
135                requested: needed,
136                available: remaining,
137            });
138        }
139        Ok(())
140    }
141
142    pub fn read_u8(&mut self) -> Result<u8> {
143        let mut buf = [0u8; 1];
144        self.read_bytes_into(&mut buf)?;
145        Ok(buf[0])
146    }
147
148    pub fn read_u16(&mut self) -> Result<u16> {
149        let mut buf = [0u8; 2];
150        self.read_bytes_into(&mut buf)?;
151        Ok(u16::from_le_bytes(buf))
152    }
153
154    pub fn read_i16(&mut self) -> Result<i16> {
155        let mut buf = [0u8; 2];
156        self.read_bytes_into(&mut buf)?;
157        Ok(i16::from_le_bytes(buf))
158    }
159
160    pub fn read_u32(&mut self) -> Result<u32> {
161        let mut buf = [0u8; 4];
162        self.read_bytes_into(&mut buf)?;
163        Ok(u32::from_le_bytes(buf))
164    }
165
166    pub fn read_i32(&mut self) -> Result<i32> {
167        let mut buf = [0u8; 4];
168        self.read_bytes_into(&mut buf)?;
169        Ok(i32::from_le_bytes(buf))
170    }
171
172    pub fn read_u64(&mut self) -> Result<u64> {
173        let mut buf = [0u8; 8];
174        self.read_bytes_into(&mut buf)?;
175        Ok(u64::from_le_bytes(buf))
176    }
177
178    pub fn read_f32(&mut self) -> Result<f32> {
179        let mut buf = [0u8; 4];
180        self.read_bytes_into(&mut buf)?;
181        Ok(f32::from_le_bytes(buf))
182    }
183
184    pub fn read_f64(&mut self) -> Result<f64> {
185        let mut buf = [0u8; 8];
186        self.read_bytes_into(&mut buf)?;
187        Ok(f64::from_le_bytes(buf))
188    }
189
190    pub fn read_i8(&mut self) -> Result<i8> {
191        let mut buf = [0u8; 1];
192        self.read_bytes_into(&mut buf)?;
193        Ok(buf[0] as i8)
194    }
195
196    /// Read a fixed-width UTF-16-LE string of `byte_len` bytes, stripping null padding.
197    pub fn read_utf16_fixed(&mut self, byte_len: usize) -> Result<String> {
198        let pos = self.pos;
199        let raw = self.read_bytes(byte_len)?;
200        if byte_len % 2 != 0 {
201            return Err(Error::InvalidUtf16(pos));
202        }
203        let units: Vec<u16> = raw
204            .chunks_exact(2)
205            .map(|c| u16::from_le_bytes([c[0], c[1]]))
206            .collect();
207        // Find null terminator
208        let end = units.iter().position(|&u| u == 0).unwrap_or(units.len());
209        String::from_utf16(&units[..end]).map_err(|_| Error::InvalidUtf16(pos))
210    }
211
212    /// Read a PascalStringWin32: UInt32 char count, then that many UTF-16-LE code units.
213    pub fn read_pascal_string(&mut self) -> Result<String> {
214        let pos = self.pos;
215        let char_count = self.read_u32()? as usize;
216        if char_count == 0 {
217            return Ok(String::new());
218        }
219        let byte_len = char_count.checked_mul(2).ok_or(Error::InvalidUtf16(pos))?;
220        let raw = self.read_bytes(byte_len)?;
221        let units: Vec<u16> = raw
222            .chunks_exact(2)
223            .map(|c| u16::from_le_bytes([c[0], c[1]]))
224            .collect();
225        // Strip trailing nulls
226        let end = units.iter().position(|&u| u == 0).unwrap_or(units.len());
227        String::from_utf16(&units[..end]).map_err(|_| Error::InvalidUtf16(pos))
228    }
229
230    /// Read a Windows FILETIME and return Unix timestamp as f64 seconds.
231    pub fn read_windows_filetime(&mut self) -> Result<f64> {
232        let ft = self.read_u64()?;
233        if ft == 0 {
234            return Ok(0.0);
235        }
236        Ok((ft as f64 / 10_000_000.0) - 11_644_473_600.0)
237    }
238}
239
240/// Compute the number of scans from the run header's declared first/last
241/// scan numbers.
242///
243/// Both inputs are untrusted u32 values read straight from the file, so
244/// `last_scan - first_scan` can be as large as `u32::MAX` (e.g.
245/// `first_scan == 0`, `last_scan == u32::MAX`); the `+ 1` therefore
246/// saturates rather than wrapping or panicking. Any resulting oversized
247/// value is caught by `BinaryReader::check_count` before it can drive an
248/// allocation.
249fn compute_num_scans(first_scan: u32, last_scan: u32) -> u32 {
250    if last_scan >= first_scan {
251        (last_scan - first_scan).saturating_add(1)
252    } else {
253        0
254    }
255}
256
257/// Convert a 1-based scan number to a `scan_index` array offset, or `None`
258/// if `scan_number` is below `first_scan_number` (a plain subtraction here
259/// would underflow rather than simply being "out of range" - both
260/// `scan_number` and `first_scan_number` can be arbitrary/untrusted values).
261fn scan_number_to_index(scan_number: u32, first_scan_number: u32) -> Option<usize> {
262    scan_number
263        .checked_sub(first_scan_number)
264        .map(|v| v as usize)
265}
266
267/// A parsed Thermo Fisher RAW file.
268pub struct RawFileReader {
269    pub header: FileHeader,
270    pub seq_row: SeqRow,
271    pub raw_file_info: RawFileInfo,
272    pub run_header: RunHeader,
273    pub scan_index: Vec<ScanIndexEntry>,
274    pub scan_events: Vec<ScanEvent>,
275    pub scan_parameters_header: GenericDataHeader,
276    pub scan_parameters: Vec<GenericRecord>,
277    pub error_log: Vec<ErrorEntry>,
278    // Instrument log uses same structure
279    pub inst_log_header: GenericDataHeader,
280    pub inst_log: Vec<GenericRecord>,
281    /// Raw file version from the header.
282    pub version: u32,
283    /// Number of scans.
284    pub num_scans: u32,
285    /// Data stream base address (for computing absolute scan offsets).
286    pub data_addr: u64,
287    /// True if scan data uses flat-peak format (TSQ/SRM) instead of PacketHeader.
288    pub flat_peaks: bool,
289    /// Detected scan-data encoding (the format used by [`Self::read_scan_peaks`]).
290    pub scan_format: crate::scan_format::ScanDataFormat,
291    /// Detected device family (informational).
292    pub device_family: crate::device::DeviceFamily,
293    /// Canonical instrument model name if one was detected in the file's
294    /// metadata region (e.g. `"Orbitrap Fusion Lumos"`). `None` means only
295    /// the coarse family could be inferred.
296    pub instrument_model: Option<&'static str>,
297    /// For SRM (flat-peak) files: maps scan_event index → Q1 precursor mass (m/z).
298    ///
299    /// Populated at open time by scanning the method/transition table stored
300    /// in the pre-scan-data header region. Empty for non-SRM instruments.
301    pub srm_q1_by_event: HashMap<u16, f64>,
302    /// For SRM (flat-peak) files: maps scan_event index → Q3 isolation window pairs (lo, hi) in m/z.
303    ///
304    /// Populated at open time by reading the Q3 window table from the first scan record
305    /// of each unique scan event class. Empty for non-SRM instruments.
306    pub srm_q3_windows: HashMap<u16, Vec<(f32, f32)>>,
307    /// For SRM (flat-peak) files: maps scan_event index → collision energy (eV).
308    ///
309    /// Populated from the v63 transition table at open time.  For v66 files the
310    /// collision energy is read from per-scan parameters instead, so this map is
311    /// empty for v66/TSQ Altis files.
312    pub srm_ce_by_event: HashMap<u16, f64>,
313}
314
315// -- Multi-controller metadata --
316
317/// Controller type codes as used in Thermo RAW files.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum ControllerType {
320    Ms,
321    Analog,
322    Adc,
323    Pda,
324    Uv,
325    Other,
326}
327
328impl ControllerType {
329    fn from_nsegs_ntrailer(ntrailer: u32, nsegs: u32) -> Self {
330        // Heuristic: MS controller always has ntrailer > 0 (v64+) or nsegs > 0.
331        // Non-MS controllers (UV, analog, PDA) have ntrailer == 0 and nsegs == 1.
332        // We can't reliably distinguish between non-MS types without parsing
333        // the InstID/method block, so we fall back to Other for those.
334        if ntrailer > 0 || nsegs > 1 {
335            Self::Ms
336        } else {
337            Self::Other
338        }
339    }
340}
341
342/// Minimal metadata about one controller in a multi-controller RAW file.
343#[derive(Debug, Clone)]
344pub struct ControllerInfo {
345    /// Zero-based controller index (position in `run_header_addrs`).
346    pub index: usize,
347    /// File offset to this controller's RunHeader.
348    pub run_header_addr: u64,
349    /// Whether this controller is the primary MS controller.
350    pub is_ms_controller: bool,
351    /// Inferred controller type.
352    pub controller_type: ControllerType,
353    /// First scan number.
354    pub first_scan: u32,
355    /// Last scan number.
356    pub last_scan: u32,
357    /// Acquisition start time (minutes).
358    pub start_time: f64,
359    /// Acquisition end time (minutes).
360    pub end_time: f64,
361}
362
363impl RawFileReader {
364    /// Open and parse a RAW file from a reader.
365    pub fn open<R: Read + Seek>(source: R) -> Result<Self> {
366        let mut r = BinaryReader::new(source);
367
368        // 1. FileHeader
369        let header = FileHeader::read(&mut r)?;
370        let version = header.version;
371
372        // 2. SeqRow
373        let seq_row = SeqRow::read(&mut r, version)?;
374
375        // 3. ASInfo (read and discard preamble + string)
376        let _as_preamble = r.read_bytes(24)?; // ASInfoPreamble: 24 bytes
377        let _as_text = r.read_pascal_string()?;
378
379        // 4. RawFileInfo
380        let raw_file_info = RawFileInfo::read(&mut r, version)?;
381
382        // 5. Extract addresses
383        let data_addr = raw_file_info.preamble.data_addr;
384
385        // 6. Select the MS controller RunHeader.
386        // Multi-controller files (e.g. UV + MS) have one RunHeader per controller.
387        // The MS controller has ntrailer > 0 (v64+) or first_scan <= last_scan with
388        // nsegs > 0 (v63 and earlier). We iterate all addresses and pick the best.
389        let run_header = {
390            let addrs = &raw_file_info.preamble.run_header_addrs;
391            let mut chosen = None;
392            for &addr in addrs {
393                if addr == 0 {
394                    continue;
395                }
396                r.seek_to(addr)?;
397                let rh = RunHeader::read(&mut r, version)?;
398                // Heuristic for identifying the MS controller:
399                // 1. For v64+: ntrailer > 0 (scan events present) - catches most instruments.
400                // 2. For all versions: RunHeader.data_addr == preamble.data_addr - the MS
401                //    controller's scan data begins at the same address the preamble declares.
402                //    This catches TSQ/triple-quad instruments where ntrailer=0 (no scan events).
403                // 3. Pre-v64 fallback: valid scan range with nsegs > 0.
404                let is_ms = if version >= 64 {
405                    rh.ntrailer > 0 || rh.data_addr == data_addr
406                } else {
407                    rh.sample_info.last_scan_number >= rh.sample_info.first_scan_number
408                        && rh.nsegs > 0
409                };
410                if is_ms {
411                    chosen = Some(rh);
412                    break;
413                }
414            }
415            // Fall back to first address if no MS controller found
416            match chosen {
417                Some(rh) => rh,
418                None => {
419                    r.seek_to(addrs[0])?;
420                    RunHeader::read(&mut r, version)?
421                }
422            }
423        };
424
425        let first_scan = run_header.sample_info.first_scan_number;
426        let last_scan = run_header.sample_info.last_scan_number;
427        let num_scans = compute_num_scans(first_scan, last_scan);
428
429        // 7. Scan index
430        r.seek_to(run_header.scan_index_addr)?;
431        r.check_count(
432            num_scans as u64,
433            ScanIndexEntry::min_size_for_version(version),
434        )?;
435        let mut scan_index = Vec::with_capacity(num_scans as usize);
436        for _ in 0..num_scans {
437            scan_index.push(ScanIndexEntry::read(&mut r, version)?);
438        }
439
440        // 8. Scan event trailer
441        r.seek_to(run_header.scan_trailer_addr)?;
442        let n_events = if version >= 64 {
443            // v64+: first u32 is a preamble (not count); use ntrailer from RunHeader
444            let _preamble = r.read_u32()?;
445            run_header.ntrailer
446        } else {
447            r.read_u32()?
448        };
449        // For v66, compute per-event body sizes from the stream's address range.
450        // The scan event stream spans [scan_trailer_addr+4 .. scan_params_addr).
451        // Each event = preamble (136 bytes) + body.
452        //
453        // Simple instruments (Q Exactive, Exploris): all events are identical in
454        // size so stream_bytes divides evenly by n_events.
455        //
456        // Tribrid instruments (Eclipse, Fusion Lumos): primary (MS1) scans and
457        // dependent (MS2+) scans have different body layouts:
458        //   Primary event:   232 bytes total (preamble 136 + body 96)
459        //   Dependent event: 344 bytes total (preamble 136 + body 208)
460        // Confirmed empirically across Orbitrap Eclipse (EThcD) and Fusion Lumos
461        // (DIA, MS3) files.
462        let preamble_size = ScanEventPreamble::size_for_version(version);
463        let (v66_body_primary, v66_body_dependent): (usize, usize) =
464            if version >= 66 && n_events > 0 {
465                let stream_bytes = run_header
466                    .scan_params_addr
467                    .saturating_sub(run_header.scan_trailer_addr)
468                    .saturating_sub(4);
469                let remainder = stream_bytes % n_events as u64;
470                if remainder == 0 {
471                    // Uniform event size (Q Exactive, Exploris, etc.)
472                    let body = (stream_bytes / n_events as u64) as usize;
473                    let body = body.saturating_sub(preamble_size);
474                    (body, body)
475                } else {
476                    // Variable-length events: tribrid Orbitrap instruments.
477                    // Known sizes: primary=232, dependent=344 (body 96 and 208).
478                    const PRIMARY_EVENT: u64 = 232;
479                    const DEPENDENT_EVENT: u64 = 344;
480                    let gap = DEPENDENT_EVENT - PRIMARY_EVENT;
481                    let n = n_events as u64;
482                    // n_primary * PRIMARY_EVENT + n_dependent * DEPENDENT_EVENT = stream_bytes
483                    // n_primary + n_dependent = n
484                    // => n_primary = (n * DEPENDENT_EVENT - stream_bytes) / gap
485                    let n_primary_numerator = n
486                        .saturating_mul(DEPENDENT_EVENT)
487                        .saturating_sub(stream_bytes);
488                    if n_primary_numerator % gap == 0 {
489                        let n_primary = n_primary_numerator / gap;
490                        let n_dependent = n.saturating_sub(n_primary);
491                        let total_check = n_primary * PRIMARY_EVENT + n_dependent * DEPENDENT_EVENT;
492                        if total_check == stream_bytes {
493                            // Verified: use the tribrid sizes.
494                            (
495                                (PRIMARY_EVENT as usize).saturating_sub(preamble_size),
496                                (DEPENDENT_EVENT as usize).saturating_sub(preamble_size),
497                            )
498                        } else {
499                            // Fallback: use floor-average uniform body
500                            let body = ((stream_bytes / n) as usize).saturating_sub(preamble_size);
501                            (body, body)
502                        }
503                    } else {
504                        // Fallback: use floor-average uniform body
505                        let body = ((stream_bytes / n) as usize).saturating_sub(preamble_size);
506                        (body, body)
507                    }
508                }
509            } else {
510                (0, 0)
511            };
512        // Each event consumes at least its preamble on disk (the body may add
513        // more, but the preamble alone is a safe lower bound for the count check).
514        r.check_count(n_events as u64, preamble_size as u64)?;
515        let mut scan_events = Vec::with_capacity(n_events as usize);
516        for _ in 0..n_events {
517            scan_events.push(ScanEvent::read(
518                &mut r,
519                version,
520                v66_body_primary,
521                v66_body_dependent,
522            )?);
523        }
524
525        // 9. Error log
526        let n_errors = run_header.sample_info.error_log_length;
527        let error_log = if n_errors > 0 {
528            r.seek_to(run_header.error_log_addr)?;
529            if version >= 64 {
530                let _preamble = r.read_u32()?;
531            }
532            // Minimum on-disk size of one entry: f32 time (4) + u32 pascal
533            // string char count (4), i.e. an empty message.
534            r.check_count(n_errors as u64, 8)?;
535            let mut log = Vec::with_capacity(n_errors as usize);
536            for _ in 0..n_errors {
537                log.push(ErrorEntry::read(&mut r)?);
538            }
539            log
540        } else {
541            // Ensure reader is positioned at error_log_addr even when empty.
542            r.seek_to(run_header.error_log_addr)?;
543            Vec::new()
544        };
545        // The GDH for scan parameters immediately follows the error-log entries.
546        // Do NOT seek back to error_log_addr - doing so would cause find_forward
547        // to scan over the scan_index (which may sit between error_log and
548        // scan_trailer in some file layouts), creating a CPU-spinning O(n) search
549        // through megabytes of binary scan data.
550        let after_error_log = r.position();
551
552        // 10. Scan parameters (trailer extra) - GenericData format in v64+.
553        //     The schema (GDH) is written just after the error-log entries;
554        //     the records are written at `scan_params_addr` (tail of file)
555        //     with NO stream preamble - records begin directly at
556        //     scan_params_addr. Any bytes after the last record are trailing
557        //     padding and can be ignored.
558        let (scan_parameters_header, scan_parameters) = if version >= 64 {
559            // Search from after the error log entries up to scan_trailer.
560            // This skips any scan_index data that may sit in between.
561            let scan_distance = run_header.scan_trailer_addr.saturating_sub(after_error_log);
562            // Estimate per-record size from the tail of the file using integer
563            // division. Any remainder bytes are trailing data, not a preamble.
564            let file_size = r.length()?;
565            let tail = file_size.saturating_sub(run_header.scan_params_addr);
566            let expected_record_size = if num_scans > 0 && tail > 0 {
567                let per_scan = tail / num_scans as u64;
568                if per_scan >= 4 {
569                    Some(per_scan as usize)
570                } else {
571                    None
572                }
573            } else {
574                None
575            };
576            match GenericDataHeader::find_forward(&mut r, scan_distance, expected_record_size)? {
577                Some(hdr) => {
578                    // Records start directly at scan_params_addr - no stream preamble.
579                    r.seek_to(run_header.scan_params_addr)?;
580                    r.check_count(num_scans as u64, hdr.fixed_record_size().max(1) as u64)?;
581                    let mut params = Vec::with_capacity(num_scans as usize);
582                    for _ in 0..num_scans {
583                        params.push(GenericRecord::read(&mut r, &hdr)?);
584                    }
585                    (hdr, params)
586                }
587                None => (GenericDataHeader { fields: Vec::new() }, Vec::new()),
588            }
589        } else {
590            (GenericDataHeader { fields: Vec::new() }, Vec::new())
591        };
592
593        // 11. Instrument log - GenericData format in v64+
594        let (inst_log_header, inst_log) = if version >= 64 {
595            r.seek_to(run_header.inst_log_addr)?;
596            match GenericDataHeader::try_read(&mut r)? {
597                Some(hdr) => {
598                    let n_inst = run_header.sample_info.inst_log_length;
599                    r.check_count(n_inst as u64, hdr.fixed_record_size().max(1) as u64)?;
600                    let mut log = Vec::with_capacity(n_inst as usize);
601                    for _ in 0..n_inst {
602                        log.push(GenericRecord::read(&mut r, &hdr)?);
603                    }
604                    (hdr, log)
605                }
606                None => (GenericDataHeader { fields: Vec::new() }, Vec::new()),
607            }
608        } else {
609            (GenericDataHeader { fields: Vec::new() }, Vec::new())
610        };
611
612        // Detect flat-peak (TSQ/SRM) format.
613        // Reliable indicator: ntrailer == 0 means no scan event trailer was written, which
614        // is the case for all TSQ/triple-quad SRM instruments.
615        // Fallback: first scan data_size < 100 (catches edge cases with tiny SRM windows).
616        // In the flat format, data_size is the number of MRM peaks, not bytes.
617        let flat_peaks = run_header.ntrailer == 0
618            || scan_index
619                .first()
620                .map(|e| e.data_size < 100)
621                .unwrap_or(false);
622
623        // Classify scan format and device family.
624        let scan_format = crate::scan_format::ScanDataFormat::detect(version, flat_peaks);
625        let first_analyzer = scan_events.first().and_then(|e| e.preamble.analyzer());
626
627        // For SRM (flat-peak) files, read the entire pre-scan-data region so that
628        // we can extract Q1 values from the method/transition table stored there.
629        // For other instruments, read only 64 KB for instrument model detection.
630        let scan_window_cap = if flat_peaks { data_addr } else { 64 * 1024u64 };
631        let window_len = scan_window_cap.min(data_addr);
632        let metadata_window = if window_len > 0 {
633            r.seek_to(0)?;
634            r.read_bytes(window_len as usize).unwrap_or_default()
635        } else {
636            Vec::new()
637        };
638        // All BinaryReader operations are complete; reclaim the underlying source so
639        // it can be used for on-demand reads (e.g. Q3 window table from scan records).
640        let mut source = r.into_inner();
641        let detected = crate::device::DeviceFamily::detect_instrument(
642            &metadata_window,
643            &header.audit_start.tag2,
644            &seq_row.inst_method,
645            first_analyzer,
646        );
647        let device_family = detected.family;
648        let instrument_model = detected.model;
649
650        // For SRM files: extract Q1 masses, Q3 window pairs, and (for v63) collision energies
651        // from the pre-scan-data header region and/or the scan data records.
652        //
653        // v66 (TSQ Quantiva / TSQ Altis, FlatV66):
654        //   Transition table layout: [Q1: f64][Q3_lo: f64][Q3_hi: f64] per channel.
655        //   Anchor: scan_index.high_mz equals the Q3_hi of the highest-Q3 channel for each
656        //   event class.  Q3 window pairs come from the per-scan record header.
657        //
658        // v63 (TSQ Quantum / TSQ Vantage, FlatV63):
659        //   Transition table layout: 72-byte records; Q1 at [+16], Q3_center at [+24],
660        //   Q3_width at [+32], CE at [+48].  scan_index.low_mz/high_mz hold the instrument
661        //   scan range (not per-transition values), so the high_mz anchor does not apply.
662        //   Q3 centers come from the first scan's peak list; Q3 windows are computed as
663        //   Q3_center ± Q3_width/2.
664        let (srm_q1_by_event, srm_q3_windows, srm_ce_by_event) = {
665            use crate::scan_format::ScanDataFormat;
666            match (flat_peaks, scan_format) {
667                (true, ScanDataFormat::FlatV66) if metadata_window.len() >= 24 => {
668                    // --- v66 Q1 extraction: anchor on scan_index.high_mz ---
669                    let mut event_q3_hi: HashMap<u16, f64> = HashMap::new();
670                    for entry in &scan_index {
671                        if entry.high_mz > 50.0 && entry.high_mz < 2000.0 {
672                            event_q3_hi.entry(entry.scan_event).or_insert(entry.high_mz);
673                        }
674                    }
675                    let data = &metadata_window;
676                    let mut q1_map: HashMap<u16, f64> = HashMap::new();
677                    'outer_v66: for (&event, &q3_hi_target) in &event_q3_hi {
678                        let end = data.len().saturating_sub(8);
679                        for i in 16..end {
680                            let hi = crate::bytes::read_f64_le(data, i)?;
681                            if (hi - q3_hi_target).abs() < 0.002 {
682                                let lo = crate::bytes::read_f64_le(data, i - 8)?;
683                                if hi > lo && (hi - lo) < 0.1 {
684                                    let q1 = crate::bytes::read_f64_le(data, i - 16)?;
685                                    if q1 > 50.0 && q1 < 3000.0 {
686                                        q1_map.insert(event, q1);
687                                        continue 'outer_v66;
688                                    }
689                                }
690                            }
691                        }
692                    }
693                    // --- v66 Q3 window extraction: read per-scan record header ---
694                    let mut seen: HashMap<u16, bool> = HashMap::new();
695                    let mut q3_map: HashMap<u16, Vec<(f32, f32)>> = HashMap::new();
696                    for entry in &scan_index {
697                        if seen.contains_key(&entry.scan_event) {
698                            continue;
699                        }
700                        seen.insert(entry.scan_event, true);
701                        if let Ok(windows) = crate::scan_data::read_scan_srm_v66_windows(
702                            &mut source,
703                            data_addr,
704                            entry.offset,
705                        ) {
706                            if !windows.is_empty() {
707                                q3_map.insert(entry.scan_event, windows);
708                            }
709                        }
710                    }
711                    (q1_map, q3_map, HashMap::new())
712                }
713                (true, ScanDataFormat::FlatV63) => {
714                    // --- v63 Q1 + Q3 window + CE extraction ---
715                    // Read peaks from the first scan of each event class; each peak's mz
716                    // is the Q3 center for that channel.  Search the pre-data region for
717                    // the Q3_center value to find Q1, Q3_width, and CE from the transition
718                    // table.  Q3 windows are computed as (Q3_center - width/2, Q3_center + width/2).
719                    let mut seen: HashMap<u16, bool> = HashMap::new();
720                    let mut q1_map: HashMap<u16, f64> = HashMap::new();
721                    let mut q3_map: HashMap<u16, Vec<(f32, f32)>> = HashMap::new();
722                    let mut ce_map: HashMap<u16, f64> = HashMap::new();
723                    let data = &metadata_window;
724                    for entry in &scan_index {
725                        let ev = entry.scan_event;
726                        if seen.contains_key(&ev) {
727                            continue;
728                        }
729                        seen.insert(ev, true);
730                        let peaks = match read_flat_peaks(
731                            &mut source,
732                            data_addr,
733                            entry.offset,
734                            entry.data_size,
735                        ) {
736                            Ok(p) if !p.is_empty() => p,
737                            _ => continue,
738                        };
739                        // Use the first peak's mz as Q3_center anchor.
740                        if let Some((q1, q3w, ce)) = search_v63_transition(data, peaks[0].mz) {
741                            q1_map.insert(ev, q1);
742                            ce_map.insert(ev, ce);
743                            let half = (q3w / 2.0) as f32;
744                            let windows: Vec<(f32, f32)> = peaks
745                                .iter()
746                                .map(|p| (p.mz as f32 - half, p.mz as f32 + half))
747                                .collect();
748                            q3_map.insert(ev, windows);
749                        }
750                    }
751                    (q1_map, q3_map, ce_map)
752                }
753                _ => (HashMap::new(), HashMap::new(), HashMap::new()),
754            }
755        };
756
757        Ok(Self {
758            header,
759            seq_row,
760            raw_file_info,
761            run_header,
762            scan_index,
763            scan_events,
764            scan_parameters_header,
765            scan_parameters,
766            error_log,
767            inst_log_header,
768            inst_log,
769            version,
770            num_scans,
771            data_addr,
772            flat_peaks,
773            scan_format,
774            device_family,
775            instrument_model,
776            srm_q1_by_event,
777            srm_q3_windows,
778            srm_ce_by_event,
779        })
780    }
781
782    /// Open a RAW file from a path.
783    pub fn open_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
784        let file = std::fs::File::open(path)?;
785        let reader = std::io::BufReader::new(file);
786        Self::open(reader)
787    }
788
789    /// Enumerate all controllers in this RAW file.
790    ///
791    /// Multi-detector acquisition systems write one [`RunHeader`] per
792    /// controller (MS, UV, PDA, Analog). This method parses all controller
793    /// headers and returns a `Vec<ControllerInfo>` with basic metadata for
794    /// each. The primary MS controller can be identified via
795    /// [`ControllerInfo::is_ms_controller`].
796    ///
797    /// For single-controller files (the common case), this returns a
798    /// one-element vec with the MS controller.
799    pub fn controllers<R: Read + Seek>(&self, source: &mut R) -> Result<Vec<ControllerInfo>> {
800        let mut r = BinaryReader::new(source);
801        let addrs = &self.raw_file_info.preamble.run_header_addrs;
802        let mut infos = Vec::with_capacity(addrs.len());
803        for (i, &addr) in addrs.iter().enumerate() {
804            if addr == 0 {
805                continue;
806            }
807            r.seek_to(addr)?;
808            let rh = RunHeader::read(&mut r, self.version)?;
809            let is_ms = if self.version >= 64 {
810                rh.ntrailer > 0 || rh.data_addr == self.data_addr
811            } else {
812                rh.nsegs > 0
813            };
814            let ct = if is_ms {
815                ControllerType::Ms
816            } else {
817                ControllerType::from_nsegs_ntrailer(rh.ntrailer, rh.nsegs)
818            };
819            infos.push(ControllerInfo {
820                index: i,
821                run_header_addr: addr,
822                is_ms_controller: is_ms,
823                controller_type: ct,
824                first_scan: rh.sample_info.first_scan_number,
825                last_scan: rh.sample_info.last_scan_number,
826                start_time: rh.sample_info.start_time,
827                end_time: rh.sample_info.end_time,
828            });
829        }
830        Ok(infos)
831    }
832
833    /// Look up the [`ScanIndexEntry`] for a 1-based scan number, bounds- and
834    /// underflow-checked.
835    ///
836    /// `scan_number` is caller-provided (and, via the public API, ultimately
837    /// file/user controlled) and `first_scan_number` is itself an untrusted
838    /// value read from the file, so a plain `scan_number - first_scan_number`
839    /// subtraction can underflow when `scan_number` is smaller - not just an
840    /// "index too large" situation. Both cases are simply out of range.
841    fn scan_index_entry(&self, scan_number: u32) -> Result<&ScanIndexEntry> {
842        let idx = scan_number_to_index(scan_number, self.run_header.sample_info.first_scan_number)
843            .ok_or(Error::AddressOutOfRange(scan_number as u64))?;
844        self.scan_index
845            .get(idx)
846            .ok_or(Error::AddressOutOfRange(scan_number as u64))
847    }
848
849    /// Read a single scan data packet (PacketHeader format).
850    pub fn read_scan<R: Read + Seek>(
851        &self,
852        source: &mut R,
853        scan_number: u32,
854    ) -> Result<ScanDataPacket> {
855        let entry = self.scan_index_entry(scan_number)?;
856        // Both are untrusted u64 values read from the file (a
857        // RawFileInfo address and a ScanIndexEntry offset); saturate
858        // instead of panicking on overflow for a corrupt combination.
859        let abs_offset = self.data_addr.saturating_add(entry.offset);
860        source.seek(SeekFrom::Start(abs_offset))?;
861        let mut r = BinaryReader::new(source);
862        ScanDataPacket::read(&mut r)
863    }
864
865    /// Read a single scan packet's centroid peaks and FT label data
866    /// (resolution / noise / baseline), skipping the profile signal for speed.
867    ///
868    /// Only valid for PacketHeader-format files; TSQ/SRM scans carry no FT
869    /// label data.
870    pub fn read_scan_labels<R: Read + Seek>(
871        &self,
872        source: &mut R,
873        scan_number: u32,
874    ) -> Result<ScanDataPacket> {
875        use crate::scan_format::ScanDataFormat;
876        if self.scan_format != ScanDataFormat::PacketHeader {
877            return Err(Error::UnsupportedOperation(
878                "centroid_labels / read_scan_labels requires a PacketHeader file (Orbitrap/ion-trap); TSQ/SRM files carry no FT label data",
879            ));
880        }
881        let entry = self.scan_index_entry(scan_number)?;
882        // Both are untrusted u64 values read from the file (a
883        // RawFileInfo address and a ScanIndexEntry offset); saturate
884        // instead of panicking on overflow for a corrupt combination.
885        let abs_offset = self.data_addr.saturating_add(entry.offset);
886        source.seek(SeekFrom::Start(abs_offset))?;
887        let mut r = BinaryReader::new(source);
888        ScanDataPacket::read_skip_profile(&mut r)
889    }
890
891    /// Read a single scan as flat peaks (TSQ/SRM format).
892    ///
893    /// In this format, `entry.offset` is the cumulative end byte offset within
894    /// the data stream. Peaks are (f32, f32) pairs at the end of each record.
895    pub fn read_scan_flat<R: Read + Seek>(
896        &self,
897        source: &mut R,
898        scan_number: u32,
899    ) -> Result<Vec<Peak>> {
900        let entry = self.scan_index_entry(scan_number)?;
901        read_flat_peaks(source, self.data_addr, entry.offset, entry.data_size)
902    }
903
904    /// Read a single scan in v66 SRM format (TSQ Quantiva / TSQ Altis).
905    ///
906    /// `entry.offset` is the START byte offset within the data stream.
907    /// The record is fixed-size (`entry.data_size` bytes) and contains:
908    ///   n_peaks (u32), header, m/z window table, then peak triplets.
909    pub fn read_scan_srm_v66<R: Read + Seek>(
910        &self,
911        source: &mut R,
912        scan_number: u32,
913    ) -> Result<Vec<Peak>> {
914        let entry = self.scan_index_entry(scan_number)?;
915        read_scan_srm_v66(source, self.data_addr, entry.offset, entry.data_size)
916    }
917
918    /// Read a single scan's peaks using whichever decoder matches this file's
919    /// scan-data format.
920    ///
921    /// This is the recommended high-level entry point. It dispatches on
922    /// [`Self::scan_format`] so callers do not have to know whether a file is
923    /// a TSQ SRM run (flat peaks) or an Orbitrap/ion-trap acquisition
924    /// (PacketHeader records).
925    ///
926    /// The returned `Vec<Peak>` contains centroided peaks regardless of the
927    /// underlying format. For PacketHeader files that also contain a profile
928    /// signal, use [`Self::read_scan`] to access both.
929    pub fn read_scan_peaks<R: Read + Seek>(
930        &self,
931        source: &mut R,
932        scan_number: u32,
933    ) -> Result<Vec<Peak>> {
934        use crate::scan_format::ScanDataFormat;
935        match self.scan_format {
936            ScanDataFormat::PacketHeader => {
937                let pkt = self.read_scan(source, scan_number)?;
938                Ok(pkt.peaks)
939            }
940            ScanDataFormat::FlatV63 => self.read_scan_flat(source, scan_number),
941            ScanDataFormat::FlatV66 => self.read_scan_srm_v66(source, scan_number),
942        }
943    }
944
945    /// Read centroided peaks only, skipping profile data.
946    ///
947    /// For PacketHeader files (Orbitrap / ion-trap), this skips the large
948    /// profile-data section, making it 2-10× faster than
949    /// [`Self::read_scan_peaks`] when only centroided m/z and intensity values
950    /// are needed (e.g. mzML export, peak area queries).
951    ///
952    /// For TSQ/SRM files this is identical to [`Self::read_scan_peaks`].
953    pub fn read_peaks_only<R: Read + Seek>(
954        &self,
955        source: &mut R,
956        scan_number: u32,
957    ) -> Result<Vec<Peak>> {
958        use crate::scan_format::ScanDataFormat;
959        match self.scan_format {
960            ScanDataFormat::PacketHeader => {
961                let entry = self.scan_index_entry(scan_number)?;
962                // Both are untrusted u64 values read from the file (a
963                // RawFileInfo address and a ScanIndexEntry offset); saturate
964                // instead of panicking on overflow for a corrupt combination.
965                let abs_offset = self.data_addr.saturating_add(entry.offset);
966                source.seek(SeekFrom::Start(abs_offset))?;
967                let mut r = BinaryReader::new(source);
968                ScanDataPacket::read_peaks_only(&mut r)
969            }
970            ScanDataFormat::FlatV63 => self.read_scan_flat(source, scan_number),
971            ScanDataFormat::FlatV66 => self.read_scan_srm_v66(source, scan_number),
972        }
973    }
974
975    /// Return the scan-parameter record for a given 1-based scan number.
976    ///
977    /// Returns `None` if the file has no scan-parameter stream or if
978    /// `scan_number` is outside the valid scan range.
979    pub fn scan_parameters(&self, scan_number: u32) -> Option<&GenericRecord> {
980        let first = self.run_header.sample_info.first_scan_number;
981        let idx = scan_number.checked_sub(first)? as usize;
982        self.scan_parameters.get(idx)
983    }
984
985    /// Return a typed view of the scan-parameter record for a given scan.
986    ///
987    /// This wraps [`Self::scan_parameters`] in a [`ScanParams`] accessor that
988    /// provides named, type-safe fields and handles label-name variations
989    /// across instrument families.
990    pub fn scan_params(&self, scan_number: u32) -> Option<ScanParams<'_>> {
991        self.scan_parameters(scan_number).map(ScanParams)
992    }
993
994    /// Return the raw instrument-log record for a given scan number, or
995    /// `None` if the scan is out of range or no instrument log was found.
996    ///
997    /// The instrument log contains per-scan instrument-state values:
998    /// temperatures, voltages, pressures, ion counts, etc.
999    pub fn inst_log_record(&self, scan_number: u32) -> Option<&GenericRecord> {
1000        let first = self.run_header.sample_info.first_scan_number;
1001        let idx = scan_number.checked_sub(first)? as usize;
1002        self.inst_log.get(idx)
1003    }
1004
1005    /// Return a typed [`StatusLogEntry`] view for the given scan number.
1006    ///
1007    /// This wraps [`Self::inst_log_record`] and provides named, type-safe
1008    /// accessors for common instrument-status fields.
1009    pub fn status_log_entry(&self, scan_number: u32) -> Option<StatusLogEntry<'_>> {
1010        self.inst_log_record(scan_number).map(StatusLogEntry)
1011    }
1012
1013    /// Return the canonical Thermo scan filter string for a given scan
1014    /// (1-based scan number), or `None` if the scan is out of range.
1015    ///
1016    /// Example output: `"FTMS + p NSI Full ms [350.0000-1500.0000]"`.
1017    ///
1018    /// See [`crate::scan_filter`] for grammar details.
1019    pub fn scan_filter(&self, scan_number: u32) -> Option<String> {
1020        let first = self.run_header.sample_info.first_scan_number;
1021        let idx = scan_number.checked_sub(first)? as usize;
1022        let entry = self.scan_index.get(idx)?;
1023
1024        // SRM files have no scan events; build the filter string from
1025        // the pre-loaded Q1 and Q3 window maps.
1026        if self.flat_peaks {
1027            let q1 = self.srm_q1_by_event.get(&entry.scan_event).copied()?;
1028            let windows = self.srm_q3_windows.get(&entry.scan_event)?;
1029            // v63 (TSQ Quantum/Vantage): NSI ionization, @cid{CE:.2} after Q1.
1030            // v66 (TSQ Quantiva/Altis): ESI ionization, no CE in filter.
1031            use crate::scan_format::ScanDataFormat;
1032            let ionization = match self.scan_format {
1033                ScanDataFormat::FlatV63 => "NSI",
1034                _ => "ESI",
1035            };
1036            let ce_part = if self.scan_format == ScanDataFormat::FlatV63 {
1037                self.srm_ce_by_event
1038                    .get(&entry.scan_event)
1039                    .map(|&ce| format!("@cid{:.2}", ce))
1040                    .unwrap_or_default()
1041            } else {
1042                String::new()
1043            };
1044            // Format: "+ c {ION} SRM ms2 {Q1:.3}{@cidCE} [{lo1:.3}-{hi1:.3}, ...]"
1045            let mut s = format!("+ c {} SRM ms2 {:.3}{}", ionization, q1, ce_part);
1046            if !windows.is_empty() {
1047                s.push(' ');
1048                s.push('[');
1049                for (i, (lo, hi)) in windows.iter().enumerate() {
1050                    if i > 0 {
1051                        s.push_str(", ");
1052                    }
1053                    s.push_str(&format!("{:.3}-{:.3}", lo, hi));
1054                }
1055                s.push(']');
1056            }
1057            return Some(s);
1058        }
1059
1060        let event = self.scan_events.get(idx)?;
1061        // Precursor m/z and activation energy come from the per-scan params
1062        // table (not the event body) for v66+. Fall back silently if missing.
1063        let params = self.scan_params(scan_number);
1064        let precursor = params.as_ref().and_then(|p| p.monoisotopic_mz());
1065        let energy = params.as_ref().and_then(|p| p.activation_energy());
1066        let supplemental = params
1067            .as_ref()
1068            .and_then(|p| p.supplemental_activation_energy());
1069        Some(crate::scan_filter::build_filter(
1070            event,
1071            entry,
1072            precursor,
1073            energy,
1074            supplemental,
1075        ))
1076    }
1077
1078    /// Return all scan retention times (minutes) in scan order (1-based scan numbers).
1079    ///
1080    /// This is equivalent to collecting `scan_index[i].start_time` for every scan.
1081    /// The returned `Vec` is indexed by `scan_number - first_scan_number`.
1082    pub fn retention_times(&self) -> Vec<f64> {
1083        self.scan_index.iter().map(|e| e.start_time).collect()
1084    }
1085
1086    /// Return a per-scan chromatogram as `(retention_time_min, tic)` pairs.
1087    pub fn tic_chromatogram(&self) -> Vec<(f64, f64)> {
1088        self.scan_index
1089            .iter()
1090            .map(|e| (e.start_time, e.total_current))
1091            .collect()
1092    }
1093
1094    /// Return a per-scan base-peak chromatogram as `(retention_time_min, bpi, base_mz)` triples.
1095    pub fn bpc_chromatogram(&self) -> Vec<(f64, f64, f64)> {
1096        self.scan_index
1097            .iter()
1098            .map(|e| (e.start_time, e.base_intensity, e.base_mz))
1099            .collect()
1100    }
1101
1102    /// Return the instrument method file path or name as stored in the
1103    /// sequence row. This is the name of the method used during acquisition
1104    /// (e.g. `"Standard_HCD.meth"`), not the embedded method text.
1105    ///
1106    /// See also [`Self::instrument_method_text`] for extracting the embedded
1107    /// XML/text method body from the file.
1108    pub fn instrument_method_name(&self) -> &str {
1109        &self.seq_row.inst_method
1110    }
1111
1112    /// Attempt to extract the embedded instrument method text from the RAW file.
1113    ///
1114    /// Thermo RAW files embed the acquisition method as a UTF-16LE text or
1115    /// XML blob in the metadata region. This method scans the bytes between
1116    /// the start of the file and the scan data for the longest contiguous
1117    /// block of valid UTF-16LE text (at least 256 characters long) and returns
1118    /// it as a `String`.
1119    ///
1120    /// Returns `None` if no suitable text block is found or if the method was
1121    /// not embedded (`method_file_present == false`).
1122    ///
1123    /// Note: This is a best-effort extraction. The result is the raw text
1124    /// content; callers may wish to trim or parse it further.
1125    pub fn instrument_method_text<R: Read + Seek>(&self, source: &mut R) -> Option<String> {
1126        if !self.raw_file_info.preamble.method_file_present {
1127            return None;
1128        }
1129        // Read metadata region: from byte 0 up to (but not including) scan data.
1130        // Cap at 512 KB to avoid reading very large files entirely.
1131        const MAX_WINDOW: u64 = 512 * 1024;
1132        let window_len = MAX_WINDOW.min(self.data_addr) as usize;
1133        if window_len < 4 {
1134            return None;
1135        }
1136        source.seek(std::io::SeekFrom::Start(0)).ok()?;
1137        let mut buf = vec![0u8; window_len];
1138        source.read_exact(&mut buf).ok()?;
1139
1140        // Scan for the longest valid UTF-16LE text block (min 256 chars = 512 bytes).
1141        // Strategy: find aligned 2-byte sequences where every pair decodes to a
1142        // printable/whitespace Unicode scalar (U+0020..U+FFFD).
1143        extract_utf16le_text(&buf, 256)
1144    }
1145}
1146
1147/// Scan `buf` for the longest contiguous UTF-16LE text block of at least
1148/// `min_chars` characters and return it as a String. Returns `None` if no
1149/// such block exists.
1150fn extract_utf16le_text(buf: &[u8], min_chars: usize) -> Option<String> {
1151    if buf.len() < 2 {
1152        return None;
1153    }
1154    let mut best: Option<String> = None;
1155    let mut best_len = 0usize;
1156
1157    // Try each even alignment (0 or 1 byte offset from start).
1158    for alignment in 0..2usize {
1159        let start = alignment;
1160        let usable = buf.len().saturating_sub(start);
1161        let n_units = usable / 2;
1162        if n_units < min_chars {
1163            continue;
1164        }
1165
1166        let mut run_start = 0usize;
1167        let mut run_chars: Vec<u16> = Vec::with_capacity(min_chars);
1168
1169        let flush = |run_chars: &Vec<u16>,
1170                     run_start: usize,
1171                     best: &mut Option<String>,
1172                     best_len: &mut usize| {
1173            if run_chars.len() >= min_chars {
1174                if let Ok(s) = String::from_utf16(run_chars) {
1175                    let _ = run_start; // suppress unused warning
1176                    if run_chars.len() > *best_len {
1177                        *best_len = run_chars.len();
1178                        *best = Some(s);
1179                    }
1180                }
1181            }
1182        };
1183
1184        for i in 0..n_units {
1185            let off = start + i * 2;
1186            let u = u16::from_le_bytes([buf[off], buf[off + 1]]);
1187            let is_ok = matches!(u, 0x0009 | 0x000A | 0x000D | 0x0020..=0xFFFD);
1188            if is_ok {
1189                run_chars.push(u);
1190            } else {
1191                flush(&run_chars, run_start, &mut best, &mut best_len);
1192                run_start = i + 1;
1193                run_chars.clear();
1194            }
1195        }
1196        flush(&run_chars, run_start, &mut best, &mut best_len);
1197    }
1198    best
1199}
1200
1201// -- High-level typed accessor for scan parameters --
1202
1203/// Typed accessor for a scan's extra parameters (`ScanParams` stream).
1204///
1205/// The underlying [`GenericRecord`] stores named fields whose labels vary
1206/// slightly across Thermo instrument families. This wrapper normalises the
1207/// most common labels so callers do not need to hard-code instrument-specific
1208/// strings.
1209///
1210/// # Example
1211/// ```no_run
1212/// use opentfraw::RawFileReader;
1213/// let raw = RawFileReader::open_path("experiment.raw").unwrap();
1214/// if let Some(p) = raw.scan_params(1) {
1215///     println!("Injection time: {:?} ms", p.ion_injection_time_ms());
1216///     println!("Charge state:   {:?}", p.charge_state());
1217/// }
1218/// ```
1219pub struct ScanParams<'a>(pub &'a GenericRecord);
1220
1221impl<'a> ScanParams<'a> {
1222    /// Return the raw `GenericRecord` for direct field access.
1223    #[inline]
1224    pub fn record(&self) -> &GenericRecord {
1225        self.0
1226    }
1227
1228    /// Ion injection / fill time in milliseconds.
1229    ///
1230    /// Label varies: `"Ion Injection Time (ms):"` (Orbitrap family) vs
1231    /// `"Ion Inject Time (ms):"` (older LTQ variants).
1232    pub fn ion_injection_time_ms(&self) -> Option<f64> {
1233        // Try canonical label first; fall back to legacy label.
1234        self.0
1235            .get_f64("Ion Injection Time (ms):")
1236            .or_else(|| self.0.get_f64("Ion Inject Time (ms):"))
1237    }
1238
1239    /// Precursor charge state (0 = unknown / MS1 scan).
1240    pub fn charge_state(&self) -> Option<i32> {
1241        self.0
1242            .get_i32("Charge State:")
1243            // Some LCQ files use UInt8 for charge state.
1244            .or_else(|| {
1245                self.0.get("Charge State:").and_then(|v| match v {
1246                    GenericValue::UInt8(n) => Some(*n as i32),
1247                    _ => None,
1248                })
1249            })
1250    }
1251
1252    /// Monoisotopic precursor m/z (0 = not determined).
1253    ///
1254    /// Tries multiple label variants for compatibility across instrument families:
1255    ///
1256    /// - `"Monoisotopic M/Z:"` - most common (Q Exactive, Orbitrap Fusion)
1257    /// - `"MS2 Isolation M/Z:"` - some older LTQ firmware
1258    ///
1259    /// Returns `None` when the value is absent or zero (not determined).
1260    pub fn monoisotopic_mz(&self) -> Option<f64> {
1261        let v = self
1262            .0
1263            .get_f64("Monoisotopic M/Z:")
1264            .or_else(|| self.0.get_f64("MS2 Isolation M/Z:"))
1265            .or_else(|| self.0.get_f64("Isolation Center M/Z:"))
1266            .or_else(|| self.0.get_f64("Precursor M/Z:"))?;
1267        if v > 0.0 {
1268            Some(v)
1269        } else {
1270            None
1271        }
1272    }
1273
1274    /// Number of micro-scans averaged into this scan.
1275    pub fn micro_scan_count(&self) -> Option<i32> {
1276        self.0.get_i32("Micro Scan Count:")
1277    }
1278
1279    /// Scan number of the master (MS1) scan that triggered this dependent scan.
1280    /// Returns `None` if this is not a dependent scan.
1281    pub fn master_scan_number(&self) -> Option<i32> {
1282        self.0
1283            .get_i32("Master Scan Number:")
1284            .or_else(|| self.0.get_i32("Master Index:"))
1285    }
1286
1287    /// Orbitrap / FT resolving power (e.g. 60000, 120000).
1288    pub fn ft_resolution(&self) -> Option<i32> {
1289        self.orbitrap_resolution()
1290    }
1291
1292    /// Number of lock masses found / matched.
1293    pub fn number_of_lm_found(&self) -> Option<i32> {
1294        self.number_of_lock_masses()
1295    }
1296
1297    /// Lock-mass m/z correction applied (ppm).
1298    pub fn lm_correction_ppm(&self) -> Option<f64> {
1299        self.lock_mass_correction_ppm()
1300    }
1301
1302    /// AGC target fill value (ion count).
1303    pub fn agc_target(&self) -> Option<i32> {
1304        self.0.get_i32("AGC Target:")
1305    }
1306
1307    /// Whether automated gain control (AGC) was active.
1308    pub fn agc_enabled(&self) -> Option<bool> {
1309        match self.0.get("AGC:")? {
1310            GenericValue::Bool(b) => Some(*b),
1311            GenericValue::String(s) => Some(s.to_ascii_lowercase().contains("on")),
1312            _ => None,
1313        }
1314    }
1315
1316    /// Elapsed scan time in seconds (Orbitrap instruments only).
1317    pub fn elapsed_scan_time_s(&self) -> Option<f64> {
1318        self.0.get_f64("Elapsed Scan Time (sec):")
1319    }
1320
1321    /// Maximum allowed ion injection time in milliseconds.
1322    pub fn max_ion_time_ms(&self) -> Option<f64> {
1323        self.0.get_f64("Max. Ion Time (ms):")
1324    }
1325
1326    /// MSn isolation window width in m/z.
1327    ///
1328    /// Label varies: `"MS2 Isolation Width:"` (most common), `"MSn Isolation Width:"`,
1329    /// or `"Isolation Width (M/Z):"` on some firmware.
1330    pub fn isolation_width_mz(&self) -> Option<f64> {
1331        self.0
1332            .get_f64("MS2 Isolation Width:")
1333            .or_else(|| self.0.get_f64("MSn Isolation Width:"))
1334            .or_else(|| self.0.get_f64("Isolation Width (M/Z):"))
1335            .or_else(|| self.0.get_f64("MS2 Isolation Width (M/Z):"))
1336    }
1337
1338    /// MSn isolation window target m/z (the center of the isolation window).
1339    ///
1340    /// Some instruments write this separately from the precursor m/z; when
1341    /// absent, callers should fall back to [`Self::monoisotopic_mz`] or to
1342    /// the event's first reaction `precursor_mz`.
1343    pub fn isolation_target_mz(&self) -> Option<f64> {
1344        self.0
1345            .get_f64("MS2 Isolation Offset:")
1346            .or_else(|| self.0.get_f64("Target M/Z:"))
1347    }
1348
1349    /// Activation energy (eV or %) for the primary activation step.
1350    ///
1351    /// Tries several label variants present across instrument families.
1352    /// NCE (normalized collision energy) labels are checked first because
1353    /// they reflect the user-set method value and are what reference tools
1354    /// (ThermoRawFileParser, Proteome Discoverer) report.  eV labels are
1355    /// used as a fallback when no NCE label is present.
1356    ///
1357    /// Label priority:
1358    /// 1. `"HCD Energy:"` / `"HCD Energy V:"` / `"CE:"` - NCE string form
1359    /// 2. `"Normalized Collision Energy:"` - ion-trap CID NCE
1360    /// 3. `"HCD Energy (eV):"` - explicit eV label (Q Exactive HF-X, Exploris)
1361    /// 4. `"HCD Energy eV:"` - eV variant
1362    /// 5. `"Collision Energy (eV):"` - ITMS CID eV
1363    pub fn activation_energy(&self) -> Option<f64> {
1364        // NCE labels: preferred because they match the user-set method value.
1365        // Skip 0.0 (sentinel for "not set").
1366        for label in &["HCD Energy:", "HCD Energy V:", "CE:"] {
1367            if let Some(s) = self.0.get_string(label) {
1368                if let Ok(v) = s.trim().trim_end_matches('%').parse::<f64>() {
1369                    if v > 0.0 {
1370                        return Some(v);
1371                    }
1372                }
1373            }
1374        }
1375        if let Some(v) = self
1376            .0
1377            .get_f64("Normalized Collision Energy:")
1378            .filter(|&v| v > 0.0)
1379        {
1380            return Some(v);
1381        }
1382        // eV labels: used when no NCE label is available.
1383        if let Some(v) = self.0.get_f64("HCD Energy (eV):").filter(|&v| v > 0.0) {
1384            return Some(v);
1385        }
1386        if let Some(v) = self.0.get_f64("HCD Energy eV:").filter(|&v| v > 0.0) {
1387            return Some(v);
1388        }
1389        self.0
1390            .get_f64("Collision Energy (eV):")
1391            .filter(|&v| v > 0.0)
1392    }
1393
1394    /// Whether the value returned by [`activation_energy`] is a normalized
1395    /// collision energy (NCE, dimensionless %) rather than an absolute eV value.
1396    ///
1397    /// Returns `true` when `activation_energy` found a value from an NCE label
1398    /// (`HCD Energy:`, `HCD Energy V:`, `CE:`, or `Normalized Collision Energy:`).
1399    /// Returns `false` when only eV labels were present or no energy was found.
1400    pub fn activation_energy_is_nce(&self) -> bool {
1401        // Returns true if activation_energy() took the NCE path.
1402        for label in &["HCD Energy:", "HCD Energy V:", "CE:"] {
1403            if let Some(s) = self.0.get_string(label) {
1404                if let Ok(v) = s.trim().trim_end_matches('%').parse::<f64>() {
1405                    if v > 0.0 {
1406                        return true;
1407                    }
1408                }
1409            }
1410        }
1411        self.0
1412            .get_f64("Normalized Collision Energy:")
1413            .filter(|&v| v > 0.0)
1414            .is_some()
1415    }
1416
1417    /// Supplemental activation energy for EThcD scans (the HCD component).
1418    ///
1419    /// Returns `None` for non-EThcD scans.
1420    pub fn supplemental_activation_energy(&self) -> Option<f64> {
1421        if let Some(v) = self.0.get_f64("Supplemental Activation CE:") {
1422            return Some(v);
1423        }
1424        if let Some(s) = self.0.get_string("Supplemental Activation:") {
1425            return s.trim().trim_end_matches('%').parse::<f64>().ok();
1426        }
1427        None
1428    }
1429
1430    /// All possible charge states reported by the precursor selection algorithm.
1431    ///
1432    /// Returns `None` when the instrument did not report possible charges.
1433    /// Some firmware stores them as a space-delimited string (e.g. `"2 3"`);
1434    /// others use a typed integer for the single selected charge.
1435    pub fn possible_charge_states(&self) -> Option<Vec<u32>> {
1436        // String variant: "2 3 4"
1437        if let Some(s) = self.0.get_string("Possible Charge States:") {
1438            let v: Vec<u32> = s
1439                .split_whitespace()
1440                .filter_map(|t| t.parse::<u32>().ok())
1441                .collect();
1442            if !v.is_empty() {
1443                return Some(v);
1444            }
1445        }
1446        // Integer variant (single charge)
1447        if let Some(c) = self.charge_state() {
1448            if c > 0 {
1449                return Some(vec![c as u32]);
1450            }
1451        }
1452        None
1453    }
1454
1455    /// FAIMS compensation voltage in V (Orbitrap Fusion/Lumos with FAIMS Pro).
1456    pub fn faims_cv(&self) -> Option<f64> {
1457        self.0
1458            .get_f64("FAIMS CV:")
1459            .or_else(|| self.0.get_f32("FAIMS CV:").map(f64::from))
1460    }
1461
1462    /// Whether FAIMS voltage was active for this scan.
1463    pub fn faims_voltage_on(&self) -> Option<bool> {
1464        match self.0.get("FAIMS Voltage On:")? {
1465            GenericValue::Bool(b) => Some(*b),
1466            GenericValue::String(s) => Some(s.to_ascii_lowercase().contains("on")),
1467            _ => None,
1468        }
1469    }
1470
1471    /// S-Lens RF level (V), typically reported on Q Exactive family.
1472    pub fn s_lens_rf_level(&self) -> Option<f64> {
1473        self.0.get_f64("S-Lens RF Level:")
1474    }
1475
1476    /// AGC fill percentage (0.0-1.0), reported on Q Exactive HF family.
1477    pub fn agc_fill(&self) -> Option<f64> {
1478        self.0.get_f64("AGC Fill:")
1479    }
1480
1481    /// Orbitrap analyzer temperature (°C), where available.
1482    pub fn analyzer_temperature(&self) -> Option<f64> {
1483        self.0.get_f64("Analyzer Temperature:")
1484    }
1485
1486    /// PS injection time in milliseconds (pre-scan injection for Q Exactive).
1487    pub fn ps_injection_time_ms(&self) -> Option<f64> {
1488        self.0.get_f64("PS Inj. Time (ms):")
1489    }
1490
1491    /// Reagent ion injection time in milliseconds (ETD reagent).
1492    pub fn reagent_ion_injection_time_ms(&self) -> Option<f64> {
1493        self.0
1494            .get_f32("Reagent Ion Injection Time (ms):")
1495            .map(f64::from)
1496    }
1497
1498    /// Whether the reagent AGC was active.
1499    pub fn reagent_ion_agc(&self) -> Option<bool> {
1500        match self.0.get("Reagent Ion AGC:")? {
1501            GenericValue::Bool(b) => Some(*b),
1502            _ => None,
1503        }
1504    }
1505
1506    /// Source CID energy applied in the ion source (eV).
1507    pub fn source_cid_energy_ev(&self) -> Option<f64> {
1508        self.0
1509            .get_f64("Source CID eV:")
1510            .or_else(|| self.0.get_f32("API Source CID Energy:").map(f64::from))
1511    }
1512
1513    /// Dynamic retention time shift in minutes (Q Exactive HF-X AutoQC).
1514    pub fn dynamic_rt_shift_min(&self) -> Option<f64> {
1515        self.0.get_f64("Dynamic RT Shift (min):")
1516    }
1517
1518    /// Lock mass correction applied (ppm) - tries several label variants.
1519    pub fn lock_mass_correction_ppm(&self) -> Option<f64> {
1520        self.0
1521            .get_f64("LM Correction (ppm):")
1522            .or_else(|| self.0.get_f64("LM m/z-Correction (ppm):"))
1523    }
1524
1525    /// Number of lock masses found.
1526    pub fn number_of_lock_masses(&self) -> Option<i32> {
1527        self.0
1528            .get_i32("Number of LM Found:")
1529            .or_else(|| self.0.get_i32("Number of Lock Masses:"))
1530    }
1531
1532    /// Orbitrap resolution setting (not measured, but requested).
1533    pub fn orbitrap_resolution(&self) -> Option<i32> {
1534        self.0
1535            .get_i32("Orbitrap Resolution:")
1536            .or_else(|| self.0.get_i32("FT Resolution:"))
1537    }
1538
1539    /// SPS (Synchronous Precursor Selection) mass for MS3 channel N (0-based index).
1540    ///
1541    /// SPS masses are stored as `"SPS Mass 1:"`, `"SPS Mass 2:"`, ... (1-based).
1542    pub fn sps_mass(&self, channel: usize) -> Option<f32> {
1543        let label = format!("SPS Mass {}:", channel + 1);
1544        self.0.get_f32(&label)
1545    }
1546
1547    /// Conversion parameter A (Orbitrap m/z conversion polynomial).
1548    pub fn conversion_parameter_a(&self) -> Option<f64> {
1549        self.0.get_f64("Conversion Parameter A:")
1550    }
1551
1552    /// Conversion parameter B.
1553    pub fn conversion_parameter_b(&self) -> Option<f64> {
1554        self.0.get_f64("Conversion Parameter B:")
1555    }
1556
1557    /// Conversion parameter C.
1558    pub fn conversion_parameter_c(&self) -> Option<f64> {
1559        self.0.get_f64("Conversion Parameter C:")
1560    }
1561
1562    /// Raw over-fill time T (used for AGC computation).
1563    pub fn raw_ovft(&self) -> Option<f64> {
1564        self.0.get_f64("RawOvFtT:")
1565    }
1566
1567    /// Error in the isotopic envelope fit (used for charge-state scoring).
1568    pub fn isotopic_fit_error(&self) -> Option<f64> {
1569        self.0.get_f64("Error in isotopic envelope fit:")
1570    }
1571
1572    /// Scan description string (arbitrary text, set by method or real-time software).
1573    pub fn scan_description(&self) -> Option<&str> {
1574        self.0.get_string("Scan Description:")
1575    }
1576
1577    /// Multi-inject info string (e.g. `"IT=45 "` for ion-trap fill time).
1578    pub fn multi_inject_info(&self) -> Option<&str> {
1579        self.0.get_string("Multi Inject Info:")
1580    }
1581
1582    /// HCD energy string - raw value as stored (may be `"28.00"`, `"28%"`, or `"N/A"`).
1583    pub fn hcd_energy(&self) -> Option<&str> {
1584        self.0
1585            .get_string("HCD Energy:")
1586            .or_else(|| self.0.get_string("HCD Energy V:"))
1587    }
1588}
1589
1590// -- Status log (instrument log) typed accessor --
1591
1592/// Typed accessor for a per-scan instrument-status log entry.
1593///
1594/// The instrument log records instrument-state values (temperatures, voltages,
1595/// pressures, etc.) at the time each scan was acquired. The schema varies
1596/// across instrument models.
1597pub struct StatusLogEntry<'a>(pub &'a GenericRecord);
1598
1599impl<'a> StatusLogEntry<'a> {
1600    /// Return the raw record for direct field access.
1601    #[inline]
1602    pub fn record(&self) -> &GenericRecord {
1603        self.0
1604    }
1605
1606    /// Ion injection time in milliseconds (present on Orbitrap family).
1607    pub fn ion_injection_time_ms(&self) -> Option<f64> {
1608        self.0
1609            .get_f64("Ion Injection Time (ms):")
1610            .or_else(|| self.0.get_f64("Ion Inject Time (ms):"))
1611    }
1612
1613    /// Orbitrap / FT resolving power setting.
1614    pub fn ft_resolution(&self) -> Option<i32> {
1615        self.0
1616            .get_i32("Orbitrap Resolution:")
1617            .or_else(|| self.0.get_i32("FT Resolution:"))
1618    }
1619
1620    /// FAIMS compensation voltage (V).
1621    pub fn faims_cv(&self) -> Option<f64> {
1622        self.0
1623            .get_f64("FAIMS CV:")
1624            .or_else(|| self.0.get_f32("FAIMS CV:").map(f64::from))
1625    }
1626
1627    /// S-Lens RF level (V).
1628    pub fn s_lens_rf_level(&self) -> Option<f64> {
1629        self.0.get_f64("S-Lens RF Level:")
1630    }
1631
1632    /// Orbitrap / analyzer temperature (°C).
1633    pub fn analyzer_temperature(&self) -> Option<f64> {
1634        self.0
1635            .get_f64("Analyzer Temperature:")
1636            .or_else(|| self.0.get_f32("Analyzer Temperature:").map(f64::from))
1637    }
1638
1639    /// API (spray) source voltage (V).
1640    pub fn spray_voltage(&self) -> Option<f64> {
1641        self.0
1642            .get_f64("Spray Voltage (V):")
1643            .or_else(|| self.0.get_f64("Spray Voltage:"))
1644            .or_else(|| self.0.get_f32("Spray Voltage:").map(f64::from))
1645    }
1646
1647    /// Lock mass reference correction (ppm).
1648    pub fn lock_mass_correction_ppm(&self) -> Option<f64> {
1649        self.0
1650            .get_f64("LM Correction (ppm):")
1651            .or_else(|| self.0.get_f64("LM m/z-Correction (ppm):"))
1652    }
1653
1654    /// Capillary temperature (°C).
1655    pub fn capillary_temperature(&self) -> Option<f64> {
1656        self.0
1657            .get_f64("Capillary Temp (°C):")
1658            .or_else(|| self.0.get_f64("Capillary Temp:"))
1659            .or_else(|| self.0.get_f32("Capillary Temp:").map(f64::from))
1660    }
1661
1662    /// Number of lock masses found.
1663    pub fn number_of_lock_masses(&self) -> Option<i32> {
1664        self.0
1665            .get_i32("Number of LM Found:")
1666            .or_else(|| self.0.get_i32("Number of Lock Masses:"))
1667    }
1668
1669    /// Get any field by name (pass-through to the underlying record).
1670    pub fn get(&self, label: &str) -> Option<&GenericValue> {
1671        self.0.get(label)
1672    }
1673
1674    /// Get a float64 field by name.
1675    pub fn get_f64(&self, label: &str) -> Option<f64> {
1676        self.0.get_f64(label)
1677    }
1678
1679    /// Get an int32 field by name.
1680    pub fn get_i32(&self, label: &str) -> Option<i32> {
1681        self.0.get_i32(label)
1682    }
1683
1684    /// Get a string field by name.
1685    pub fn get_string(&self, label: &str) -> Option<&str> {
1686        self.0.get_string(label)
1687    }
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692    use super::*;
1693    use std::io::Cursor;
1694
1695    // Regression tests for a fuzzer-found crash: with `first_scan = 0` and
1696    // `last_scan = u32::MAX`, the old `last_scan - first_scan + 1`
1697    // computation overflowed u32 and panicked under debug assertions.
1698    #[test]
1699    fn compute_num_scans_normal_range() {
1700        assert_eq!(compute_num_scans(1, 5), 5);
1701        assert_eq!(compute_num_scans(1, 1), 1);
1702    }
1703
1704    #[test]
1705    fn compute_num_scans_last_before_first_is_zero() {
1706        assert_eq!(compute_num_scans(10, 3), 0);
1707    }
1708
1709    #[test]
1710    fn compute_num_scans_saturates_instead_of_overflowing() {
1711        assert_eq!(compute_num_scans(0, u32::MAX), u32::MAX);
1712        assert_eq!(compute_num_scans(u32::MAX, u32::MAX), 1);
1713    }
1714
1715    // Regression test for a second fuzzer-found crash: `data_addr +
1716    // entry.offset` (both untrusted u64 values) overflowed and panicked for
1717    // a crafted combination near u64::MAX.
1718    #[test]
1719    fn saturating_add_of_addr_and_offset_does_not_panic() {
1720        let data_addr = u64::MAX - 5;
1721        let offset = 100u64;
1722        assert_eq!(data_addr.saturating_add(offset), u64::MAX);
1723    }
1724
1725    // Regression test for a third fuzzer-found crash: `read_scan` /
1726    // `read_scan_flat` / `read_scan_srm_v66` / `read_peaks_only` computed
1727    // `scan_number - first_scan_number` directly, which underflowed (and
1728    // panicked under debug assertions) whenever a requested `scan_number`
1729    // fell below the file's declared `first_scan_number` - reachable simply
1730    // by asking for an out-of-range scan on a file with a large
1731    // `first_scan_number`.
1732    #[test]
1733    fn scan_number_to_index_normal_case() {
1734        assert_eq!(scan_number_to_index(5, 1), Some(4));
1735        assert_eq!(scan_number_to_index(1, 1), Some(0));
1736    }
1737
1738    #[test]
1739    fn scan_number_to_index_below_first_scan_is_none_not_a_panic() {
1740        assert_eq!(scan_number_to_index(0, 5), None);
1741        assert_eq!(scan_number_to_index(0, u32::MAX), None);
1742    }
1743
1744    #[test]
1745    fn read_bytes_returns_requested_slice() {
1746        let mut r = BinaryReader::new(Cursor::new(vec![1, 2, 3, 4, 5]));
1747        assert_eq!(r.read_bytes(3).unwrap(), vec![1, 2, 3]);
1748        assert_eq!(r.read_bytes(2).unwrap(), vec![4, 5]);
1749    }
1750
1751    #[test]
1752    fn read_bytes_past_end_is_eof_not_a_huge_allocation() {
1753        let mut r = BinaryReader::new(Cursor::new(vec![1u8, 2, 3]));
1754        let err = r.read_bytes(10).unwrap_err();
1755        assert!(matches!(err, Error::AllocationTooLarge { .. }));
1756    }
1757
1758    #[test]
1759    fn read_bytes_rejects_implausible_declared_length() {
1760        // A corrupt/crafted file declaring an enormous length must be
1761        // rejected before any allocation is attempted, not after.
1762        let mut r = BinaryReader::new(Cursor::new(vec![0u8; 16]));
1763        let err = r.read_bytes(1_usize << 40).unwrap_err();
1764        match err {
1765            Error::AllocationTooLarge {
1766                requested,
1767                available,
1768                ..
1769            } => {
1770                assert_eq!(requested, 1u64 << 40);
1771                assert_eq!(available, 16);
1772            }
1773            other => panic!("expected AllocationTooLarge, got {other:?}"),
1774        }
1775    }
1776
1777    #[test]
1778    fn check_count_allows_exact_fit() {
1779        let mut r = BinaryReader::new(Cursor::new(vec![0u8; 16]));
1780        assert!(r.check_count(2, 8).is_ok());
1781    }
1782
1783    #[test]
1784    fn check_count_rejects_when_short_by_one_byte() {
1785        let mut r = BinaryReader::new(Cursor::new(vec![0u8; 15]));
1786        assert!(r.check_count(2, 8).is_err());
1787    }
1788
1789    #[test]
1790    fn check_count_accounts_for_current_position() {
1791        let mut r = BinaryReader::new(Cursor::new(vec![0u8; 16]));
1792        r.skip(10).unwrap();
1793        // Only 6 bytes remain; asking for 1 item of 8 bytes must fail.
1794        assert!(r.check_count(1, 8).is_err());
1795        assert!(r.check_count(1, 6).is_ok());
1796    }
1797
1798    #[test]
1799    fn check_count_saturates_instead_of_overflowing() {
1800        let mut r = BinaryReader::new(Cursor::new(vec![0u8; 4]));
1801        // count * min_item_bytes would overflow a u64 multiply; this must
1802        // not panic and must still be reported as too large.
1803        assert!(r.check_count(u64::MAX, u64::MAX).is_err());
1804    }
1805
1806    #[test]
1807    fn length_is_cached_across_calls() {
1808        let mut r = BinaryReader::new(Cursor::new(vec![0u8; 100]));
1809        assert_eq!(r.length().unwrap(), 100);
1810        r.skip(50).unwrap();
1811        // Still reports the whole-stream length, not affected by position.
1812        assert_eq!(r.length().unwrap(), 100);
1813    }
1814
1815    #[test]
1816    fn pascal_string_with_implausible_char_count_is_rejected_before_allocating() {
1817        let mut bytes = u32::MAX.to_le_bytes().to_vec(); // char_count
1818        bytes.extend_from_slice(&[0u8; 8]); // far short of the declared length
1819        let mut r = BinaryReader::new(Cursor::new(bytes));
1820        assert!(r.read_pascal_string().is_err());
1821    }
1822
1823    #[test]
1824    fn utf16_fixed_strips_null_padding() {
1825        let mut bytes = Vec::new();
1826        for u in "hi".encode_utf16() {
1827            bytes.extend_from_slice(&u.to_le_bytes());
1828        }
1829        bytes.extend_from_slice(&[0, 0, 0, 0]); // null padding
1830        let mut r = BinaryReader::new(Cursor::new(bytes));
1831        assert_eq!(r.read_utf16_fixed(8).unwrap(), "hi");
1832    }
1833
1834    #[test]
1835    fn windows_filetime_zero_is_zero() {
1836        let bytes = 0u64.to_le_bytes().to_vec();
1837        let mut r = BinaryReader::new(Cursor::new(bytes));
1838        assert_eq!(r.read_windows_filetime().unwrap(), 0.0);
1839    }
1840}