Skip to main content

gwseq_io/bam/
bgzf.rs

1//! BGZF: gzip members with an extra field carrying each block's compressed
2//! size, so a virtual offset can address a record inside one.
3//!
4//! Kept out of `reader.rs`, because it is a distinct format concern — a 48-bit block offset with a 16-bit offset inside it, an
5//! EOF marker block, a header whose magic has to be checked before its size
6//! field is trusted — and off-by-one here reads plausible garbage.
7
8use std::io::Read;
9
10use bytes::{Bytes, BytesMut};
11
12use crate::error::{Error, Result};
13use crate::source::ByteSource;
14
15pub const HEADER_SIZE: usize = 18;
16pub const EOF_SIZE: usize = 28;
17/// The largest a BGZF block can be: `BSIZE` is a `u16` holding size − 1.
18pub const MAX_BLOCK_SIZE: usize = 65536;
19
20/// The 28-byte empty block every BAM ends with.
21///
22/// A file without it is truncated, and the reader says so at open rather than
23/// after walking to the last record.
24pub static BGZF_EOF_BLOCK: [u8; EOF_SIZE] = [
25    0x1f, 0x8b, 0x08, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x06, 0x00, 0x42, 0x43, 0x02, 0x00,
26    0x1b, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
27];
28
29/// A BAI virtual offset: the block's file offset in the top 48 bits, the offset
30/// within the decompressed block in the low 16.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
32pub struct VirtualOffset(pub u64);
33
34impl VirtualOffset {
35    #[inline]
36    pub fn block_offset(self) -> u64 {
37        self.0 >> 16
38    }
39
40    #[inline]
41    pub fn within_block(self) -> usize {
42        (self.0 & 0xFFFF) as usize
43    }
44
45    #[inline]
46    pub fn new(block_offset: u64, within_block: u16) -> Self {
47        VirtualOffset((block_offset << 16) | within_block as u64)
48    }
49}
50
51/// Check that a block header is one, before its size field is believed.
52///
53/// The block size drives the whole walk and is read out of these very bytes, so
54/// on a file that is not BGZF — or one whose blocks do not start where the index
55/// says — whatever sits there would be taken for a length.
56fn check_block_header(head: &[u8], path: &str, at: u64) -> Result<()> {
57    let ok = head[0] == 0x1f
58        && head[1] == 0x8b
59        && head[2] == 8
60        && (head[3] & 0x04) != 0
61        && head[12] == b'B'
62        && head[13] == b'C'
63        && u16::from_le_bytes([head[14], head[15]]) == 2;
64    if ok {
65        Ok(())
66    } else {
67        Err(Error::corrupt(
68            path,
69            at,
70            format!(
71                "no bgzf block header at {at}, so the file is corrupt or its index \
72                 does not belong to it"
73            ),
74        ))
75    }
76}
77
78/// Decompressed size of a BGZF block, from its `BSIZE` field.
79///
80/// Widened before the increment: `BSIZE` holds the size less one, so a
81/// spec-legal 64 KiB block stores 65535 and a `u16` sum would wrap it to 0 and
82/// stall the walk.
83#[inline]
84fn block_size(head: &[u8]) -> usize {
85    u16::from_le_bytes([head[16], head[17]]) as usize + 1
86}
87
88/// A half-open run of the file, as the BAI describes one.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct Chunk {
91    pub begin: VirtualOffset,
92    pub end: VirtualOffset,
93}
94
95impl Chunk {
96    /// Compressed bytes the chunk spans, which is what a run of them weighs.
97    #[inline]
98    pub fn compressed_size(&self) -> u64 {
99        self.end
100            .block_offset()
101            .saturating_sub(self.begin.block_offset())
102    }
103}
104
105/// The alignment records one index chunk holds, decompressed and trimmed to the
106/// two virtual offsets it lies between.
107///
108/// Returns whole records starting at a record boundary — which is what a virtual
109/// offset names — so the result can be handed straight to the record decoder.
110pub fn decompress_chunk(source: &dyn ByteSource, chunk: Chunk, path: &str) -> Result<Bytes> {
111    let first_block = chunk.begin.block_offset();
112    let last_block = chunk.end.block_offset();
113    let begin_within = chunk.begin.within_block();
114    let end_within = chunk.end.within_block();
115
116    // A whole block past the chunk's own end, so the last one arrives complete
117    // however far into it the chunk stops. The end offset is never before the
118    // start, so this is at least one block on its own.
119    let wanted = (last_block - first_block) as usize + MAX_BLOCK_SIZE;
120    // Short at the end of the file, which is where a read deliberately asking
121    // for one block more than the chunk spans has nothing behind it.
122    let raw = source.read_at(first_block, wanted)?;
123
124    let mut out = BytesMut::new();
125    let mut index = 0usize;
126    // Every way out of this loop but the two breaks below is an error: a silent
127    // break on "not enough data" would let a truncated file come back as
128    // however many alignments survived the cut.
129    loop {
130        let at = first_block + index as u64;
131        // Past the last block the chunk names: a virtual offset points at a
132        // record *inside* the block it names, so nothing beyond it is ours.
133        if at > last_block {
134            break;
135        }
136        // The chunk ends on a block boundary, so the block starting there holds
137        // none of it and need not even be present.
138        if at == last_block && end_within == 0 {
139            break;
140        }
141
142        if index + HEADER_SIZE > raw.len() {
143            return Err(Error::corrupt(
144                path,
145                at,
146                format!(
147                    "the bgzf block at {at} is cut short (its {HEADER_SIZE}-byte header \
148                     does not fit what is left of the file)"
149                ),
150            ));
151        }
152        let head = &raw[index..index + HEADER_SIZE];
153        check_block_header(head, path, at)?;
154        let size = block_size(head);
155        if size < HEADER_SIZE {
156            return Err(Error::corrupt(
157                path,
158                at,
159                format!(
160                    "the bgzf block at {at} declares {size} bytes, which is less than \
161                     its own header"
162                ),
163            ));
164        }
165        if index + size > raw.len() {
166            return Err(Error::corrupt(
167                path,
168                at,
169                format!(
170                    "the bgzf block at {at} declares {size} bytes and only {} are left, \
171                     so the file is truncated",
172                    raw.len() - index
173                ),
174            ));
175        }
176
177        let block = inflate(&raw[index..index + size], path, at)?;
178        index += size;
179
180        // The chunk starts partway into its first block and ends partway into
181        // its last; every block between is taken whole. Both can be the same
182        // block, in which case both trims apply to it.
183        let from = if at == first_block { begin_within } else { 0 };
184        let to = if at == last_block {
185            end_within.min(block.len())
186        } else {
187            block.len()
188        };
189        if to > from {
190            out.extend_from_slice(&block[from..to]);
191        }
192    }
193    Ok(out.freeze())
194}
195
196/// Inflate one BGZF block, which is a gzip member.
197fn inflate(block: &[u8], path: &str, at: u64) -> Result<Vec<u8>> {
198    let mut out = Vec::with_capacity(MAX_BLOCK_SIZE);
199    flate2::read::GzDecoder::new(block)
200        .read_to_end(&mut out)
201        .map_err(|e| {
202            Error::corrupt(
203                path,
204                at,
205                format!("could not inflate the bgzf block at {at}: {e}"),
206            )
207        })?;
208    Ok(out)
209}
210
211/// Check that the file ends with the EOF marker block.
212///
213/// Done before the header, so a truncated file is refused rather than opened
214/// and read short.
215pub fn check_eof(source: &dyn ByteSource) -> Result<()> {
216    let path = source.path();
217    let len = source.len()?;
218    if len < EOF_SIZE as u64 {
219        return Err(Error::format(
220            path,
221            "file is too short to be a bam (it does not hold even the bgzf end-of-file block)",
222        ));
223    }
224    let tail = source.read_exact_at(len - EOF_SIZE as u64, EOF_SIZE)?;
225    if tail[..] != BGZF_EOF_BLOCK[..] {
226        return Err(Error::format(
227            path,
228            "bam file is truncated (it does not end with the bgzf end-of-file block)",
229        ));
230    }
231    Ok(())
232}
233
234/// Sequential inflated bytes from the start of a BGZF file.
235///
236/// What the header reader walks: the header is at the front and is not addressed
237/// by any virtual offset, so it is read as an ordinary multi-member gzip stream
238/// rather than block by block.
239pub fn header_reader(source: &dyn ByteSource) -> impl Read + '_ {
240    flate2::read::MultiGzDecoder::new(SourceReader {
241        source,
242        offset: 0,
243        buffer: Bytes::new(),
244    })
245}
246
247/// A [`ByteSource`] as a sequential [`Read`], reading a block at a time.
248struct SourceReader<'a> {
249    source: &'a dyn ByteSource,
250    offset: u64,
251    buffer: Bytes,
252}
253
254impl Read for SourceReader<'_> {
255    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
256        if self.buffer.is_empty() {
257            self.buffer = self
258                .source
259                .read_at(self.offset, MAX_BLOCK_SIZE)
260                .map_err(std::io::Error::other)?;
261            if self.buffer.is_empty() {
262                return Ok(0);
263            }
264            self.offset += self.buffer.len() as u64;
265        }
266        let take = out.len().min(self.buffer.len());
267        out[..take].copy_from_slice(&self.buffer[..take]);
268        self.buffer = self.buffer.slice(take..);
269        Ok(take)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::source::testing::MemorySource;
277    use std::io::Write;
278
279    /// One BGZF block wrapping `payload`.
280    fn block(payload: &[u8]) -> Vec<u8> {
281        let mut encoder =
282            flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::new(6));
283        encoder.write_all(payload).unwrap();
284        let deflated = encoder.finish().unwrap();
285
286        let total = HEADER_SIZE + deflated.len() + 8;
287        let mut out = Vec::with_capacity(total);
288        out.extend_from_slice(&[0x1f, 0x8b, 8, 4, 0, 0, 0, 0, 0, 0xff]);
289        out.extend_from_slice(&6u16.to_le_bytes()); // XLEN
290        out.extend_from_slice(b"BC");
291        out.extend_from_slice(&2u16.to_le_bytes());
292        out.extend_from_slice(&((total - 1) as u16).to_le_bytes()); // BSIZE
293        out.extend_from_slice(&deflated);
294        out.extend_from_slice(&crc32(payload).to_le_bytes());
295        out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
296        assert_eq!(out.len(), total);
297        out
298    }
299
300    fn crc32(data: &[u8]) -> u32 {
301        let mut hasher = flate2::Crc::new();
302        hasher.update(data);
303        hasher.sum()
304    }
305
306    #[test]
307    fn a_virtual_offset_splits_into_its_two_halves() {
308        let offset = VirtualOffset::new(0x0001_2345_6789, 0xABCD);
309        assert_eq!(offset.block_offset(), 0x0001_2345_6789);
310        assert_eq!(offset.within_block(), 0xABCD);
311        // The top 48 bits and the low 16, packed as the format packs them.
312        assert_eq!(offset.0, 0x0001_2345_6789_ABCD);
313    }
314
315    #[test]
316    fn a_chunk_inside_one_block_is_trimmed_at_both_ends() {
317        let payload: Vec<u8> = (0..200u8).collect();
318        let source = MemorySource::new(block(&payload));
319        let chunk = Chunk {
320            begin: VirtualOffset::new(0, 50),
321            end: VirtualOffset::new(0, 120),
322        };
323        let got = decompress_chunk(&source, chunk, "test").unwrap();
324        assert_eq!(&got[..], &payload[50..120]);
325    }
326
327    #[test]
328    fn a_chunk_spanning_blocks_takes_the_middle_ones_whole() {
329        let first: Vec<u8> = (0..100u8).collect();
330        let second: Vec<u8> = (100..200u8).collect();
331        let third: Vec<u8> = (200..250u8).collect();
332        let mut bytes = block(&first);
333        let second_at = bytes.len() as u64;
334        bytes.extend_from_slice(&block(&second));
335        let third_at = bytes.len() as u64;
336        bytes.extend_from_slice(&block(&third));
337        let source = MemorySource::new(bytes);
338
339        let chunk = Chunk {
340            begin: VirtualOffset::new(0, 90),
341            end: VirtualOffset::new(third_at, 10),
342        };
343        let got = decompress_chunk(&source, chunk, "test").unwrap();
344        let mut expected = first[90..].to_vec();
345        expected.extend_from_slice(&second);
346        expected.extend_from_slice(&third[..10]);
347        assert_eq!(&got[..], &expected[..]);
348        assert!(second_at > 0);
349    }
350
351    #[test]
352    fn a_chunk_ending_on_a_block_boundary_does_not_need_that_block() {
353        let first: Vec<u8> = (0..100u8).collect();
354        let bytes = block(&first);
355        let end_at = bytes.len() as u64;
356        // The block at `end_at` is absent from the file entirely.
357        let source = MemorySource::new(bytes);
358        let chunk = Chunk {
359            begin: VirtualOffset::new(0, 0),
360            end: VirtualOffset::new(end_at, 0),
361        };
362        assert_eq!(
363            &decompress_chunk(&source, chunk, "test").unwrap()[..],
364            &first[..]
365        );
366    }
367
368    #[test]
369    fn something_that_is_not_a_bgzf_block_is_refused_by_name() {
370        let source = MemorySource::new(vec![0u8; 4096]);
371        let chunk = Chunk {
372            begin: VirtualOffset::new(0, 0),
373            end: VirtualOffset::new(0, 10),
374        };
375        let err = decompress_chunk(&source, chunk, "test")
376            .unwrap_err()
377            .to_string();
378        assert!(err.contains("no bgzf block header"), "{err}");
379    }
380
381    #[test]
382    fn a_truncated_block_is_corrupt_not_a_short_read() {
383        let payload: Vec<u8> = (0..200u8).collect();
384        let mut bytes = block(&payload);
385        bytes.truncate(bytes.len() - 10);
386        let source = MemorySource::new(bytes);
387        let chunk = Chunk {
388            begin: VirtualOffset::new(0, 0),
389            end: VirtualOffset::new(0, 200),
390        };
391        let err = decompress_chunk(&source, chunk, "test")
392            .unwrap_err()
393            .to_string();
394        assert!(err.contains("truncated"), "{err}");
395    }
396
397    #[test]
398    fn the_eof_block_is_what_says_a_file_is_whole() {
399        let mut bytes = block(b"hello");
400        bytes.extend_from_slice(&BGZF_EOF_BLOCK);
401        assert!(check_eof(&MemorySource::new(bytes.clone())).is_ok());
402
403        bytes.truncate(bytes.len() - 1);
404        let err = check_eof(&MemorySource::new(bytes))
405            .unwrap_err()
406            .to_string();
407        assert!(err.contains("truncated"), "{err}");
408
409        let err = check_eof(&MemorySource::new(vec![0u8; 4]))
410            .unwrap_err()
411            .to_string();
412        assert!(err.contains("too short"), "{err}");
413    }
414
415    #[test]
416    fn the_header_reader_walks_every_member() {
417        let mut bytes = block(b"BAM\x01first ");
418        bytes.extend_from_slice(&block(b"second"));
419        bytes.extend_from_slice(&BGZF_EOF_BLOCK);
420        let source = MemorySource::new(bytes);
421        let mut text = Vec::new();
422        header_reader(&source).read_to_end(&mut text).unwrap();
423        assert_eq!(&text, b"BAM\x01first second");
424    }
425
426    #[test]
427    fn a_chunk_compressed_size_is_its_block_span() {
428        let chunk = Chunk {
429            begin: VirtualOffset::new(1000, 5),
430            end: VirtualOffset::new(9000, 7),
431        };
432        assert_eq!(chunk.compressed_size(), 8000);
433    }
434}