Skip to main content

gwseq_io/bam/
header.rs

1//! The BAM header: the SAM text block and the binary reference list.
2
3use std::io::Read;
4
5use crate::error::{Error, Result};
6use crate::genomic::ChrMap;
7use crate::source::ByteSource;
8
9#[derive(Debug, Clone)]
10pub struct HeaderField {
11    pub tag: String,
12    pub value: String,
13}
14
15/// One `@`-prefixed line: its two-letter type and its fields.
16#[derive(Debug, Clone)]
17pub struct HeaderLine {
18    pub kind: String,
19    pub fields: Vec<HeaderField>,
20}
21
22#[derive(Debug, Clone, Default)]
23pub struct SamHeader {
24    pub lines: Vec<HeaderLine>,
25}
26
27impl SamHeader {
28    /// Parse the text block: `@HD\tVN:1.6\tSO:coordinate`, one line each.
29    ///
30    /// A field without a colon is kept with an empty tag rather than dropped —
31    /// the header is the file's, and a reader that silently discards part of it
32    /// is worse than one that hands it over as it stands.
33    pub fn parse(text: &str) -> Self {
34        let lines = text
35            .lines()
36            .filter_map(|line| {
37                let line = line.strip_prefix('@')?;
38                let mut parts = line.split('\t');
39                let kind = parts.next()?.to_string();
40                let fields = parts
41                    .filter(|f| !f.is_empty())
42                    .map(|field| match field.split_once(':') {
43                        Some((tag, value)) => HeaderField {
44                            tag: tag.to_string(),
45                            value: value.to_string(),
46                        },
47                        None => HeaderField {
48                            tag: String::new(),
49                            value: field.to_string(),
50                        },
51                    })
52                    .collect();
53                Some(HeaderLine { kind, fields })
54            })
55            .collect();
56        Self { lines }
57    }
58}
59
60/// Read the header and the reference list.
61///
62/// Reference names come from the **binary list**, not from the `@SQ` lines: the
63/// two can disagree, and the binary list is what record `ref_id`s index into.
64pub fn read(source: &dyn ByteSource) -> Result<(SamHeader, ChrMap)> {
65    let path = source.path();
66    let mut reader = super::bgzf::header_reader(source);
67
68    let mut magic = [0u8; 4];
69    read_exact(&mut reader, &mut magic, path)?;
70    if &magic != b"BAM\x01" {
71        return Err(Error::format(path, "invalid bam magic string"));
72    }
73
74    let l_text = read_u32(&mut reader, path)? as usize;
75    let text = read_sized(&mut reader, l_text, MAX_TEXT, "header text", path)?;
76    let header = SamHeader::parse(&String::from_utf8_lossy(&text));
77
78    let n_ref = read_u32(&mut reader, path)? as usize;
79    let mut entries = Vec::with_capacity(n_ref.min(1 << 16));
80    for index in 0..n_ref {
81        let l_name = read_u32(&mut reader, path)? as usize;
82        if l_name == 0 {
83            return Err(Error::corrupt(path, 0, "a bam reference has an empty name"));
84        }
85        let name = read_sized(&mut reader, l_name, MAX_NAME, "reference name", path)?;
86        // NUL-terminated in the file; the terminator is not part of the name.
87        let name = String::from_utf8_lossy(name.strip_suffix(&[0]).unwrap_or(&name)).into_owned();
88        let size = read_u32(&mut reader, path)? as i64;
89        entries.push((name, size, index));
90    }
91    Ok((header, ChrMap::from_indexed_entries(entries)))
92}
93
94/// A bam header longer than this is not a header. Real ones run to a few MB on
95/// a scaffolded assembly with a long `@PG` chain; a quarter of a gibibyte is
96/// past anything a tool writes and short of anything that hurts to allocate.
97const MAX_TEXT: usize = 256 << 20;
98
99/// A reference name longer than this is not a name. The SAM spec's own regex
100/// puts no bound on it, but a `u32` field does, and a corrupt one asks for
101/// 4 GiB.
102const MAX_NAME: usize = 64 << 10;
103
104/// Read `len` bytes, refusing an implausible `len` before anything is
105/// allocated and growing into what the file really holds rather than reserving
106/// the number it named.
107///
108/// The `take` is what makes the difference: `vec![0u8; len]` allocates and
109/// zeroes `len` bytes before a single one has been read, and a failed
110/// allocation is an abort no `Result` can carry.
111fn read_sized(
112    reader: &mut impl Read,
113    len: usize,
114    max: usize,
115    what: &str,
116    path: &str,
117) -> Result<Vec<u8>> {
118    if len > max {
119        return Err(Error::corrupt(
120            path,
121            0,
122            format!("bam {what} declares {len} bytes, more than the {max} this reader allows"),
123        ));
124    }
125    let mut buf = Vec::new();
126    reader
127        .take(len as u64)
128        .read_to_end(&mut buf)
129        .map_err(|e| Error::corrupt(path, 0, format!("bam header ended early: {e}")))?;
130    if buf.len() != len {
131        return Err(Error::corrupt(
132            path,
133            0,
134            format!(
135                "bam {what} declares {len} bytes and the file holds {}",
136                buf.len()
137            ),
138        ));
139    }
140    Ok(buf)
141}
142
143fn read_exact(reader: &mut impl Read, buf: &mut [u8], path: &str) -> Result<()> {
144    reader
145        .read_exact(buf)
146        .map_err(|e| Error::corrupt(path, 0, format!("bam header ended early: {e}")))
147}
148
149fn read_u32(reader: &mut impl Read, path: &str) -> Result<u32> {
150    let mut buf = [0u8; 4];
151    read_exact(reader, &mut buf, path)?;
152    Ok(u32::from_le_bytes(buf))
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn header_lines_split_into_typed_fields() {
161        let header = SamHeader::parse("@HD\tVN:1.6\tSO:coordinate\n@SQ\tSN:chr1\tLN:1000\n");
162        assert_eq!(header.lines.len(), 2);
163        assert_eq!(header.lines[0].kind, "HD");
164        assert_eq!(header.lines[0].fields[1].tag, "SO");
165        assert_eq!(header.lines[0].fields[1].value, "coordinate");
166        assert_eq!(header.lines[1].fields[0].value, "chr1");
167    }
168
169    #[test]
170    fn a_value_carrying_colons_keeps_all_but_the_first() {
171        // A @PG CL: field is a whole command line, colons and all.
172        let header = SamHeader::parse("@PG\tID:x\tCL:bwa mem -R @RG\\tID:1 ref.fa\n");
173        assert_eq!(header.lines[0].fields[1].tag, "CL");
174        assert!(header.lines[0].fields[1].value.contains("ID:1"));
175    }
176
177    #[test]
178    fn a_field_with_no_colon_is_kept_rather_than_dropped() {
179        let header = SamHeader::parse("@CO\tsome free text\n");
180        assert_eq!(header.lines[0].kind, "CO");
181        assert_eq!(header.lines[0].fields[0].tag, "");
182        assert_eq!(header.lines[0].fields[0].value, "some free text");
183    }
184
185    #[test]
186    fn lines_that_are_not_header_lines_are_skipped() {
187        let header = SamHeader::parse("@HD\tVN:1.6\nnot a header line\n\n@SQ\tSN:chr1\n");
188        assert_eq!(header.lines.len(), 2);
189        assert_eq!(header.lines[1].kind, "SQ");
190    }
191
192    #[test]
193    fn an_empty_header_parses_to_nothing() {
194        assert!(SamHeader::parse("").lines.is_empty());
195    }
196}