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}