Skip to main content

document_svg/document/
sam.rs

1//! Bounded SAM (Sequence Alignment/Map) text alignment preview.
2//!
3//! Header lines, the 11 mandatory alignment columns, and optional tags are
4//! rendered as inert table data. Reference lookup, CIGAR projection, genotype
5//! interpretation, and external resource access are intentionally omitted.
6
7use std::collections::HashSet;
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::error::{Error, Result};
12use crate::table::{TableAlign, TableData, convert_table_pages};
13
14const MAX_SAM_BYTES: u64 = 128 * 1024 * 1024;
15const MAX_SAM_LINES: usize = 2_000_000;
16const MAX_SAM_LINE_BYTES: usize = 1 << 20;
17const MAX_SAM_RECORDS: usize = 100_000;
18const MAX_SAM_COLUMNS: usize = 256;
19const MAX_SAM_VALUE_BYTES: usize = 64 * 1024;
20const MAX_SAM_CELLS: usize = 2_000_000;
21
22pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
23    let Ok(text) = std::str::from_utf8(prefix) else {
24        return false;
25    };
26    if text
27        .lines()
28        .any(|line| line.starts_with("@HD\t") || line.starts_with("@SQ\t"))
29    {
30        return true;
31    }
32    text.lines()
33        .map(str::trim)
34        .filter(|line| !line.is_empty() && !line.starts_with('@'))
35        .find_map(|line| {
36            let fields = line.split('\t').collect::<Vec<_>>();
37            (fields.len() >= 11
38                && fields[1].parse::<u16>().is_ok()
39                && fields[3].parse::<u64>().is_ok())
40            .then_some(())
41        })
42        .is_some()
43}
44
45pub(crate) fn convert(
46    path: &Path,
47    options: &ConvertOptions,
48    sink: &mut dyn PageConsumer,
49) -> Result<Vec<String>> {
50    let bytes = read_limited_file(
51        path,
52        options.max_input_bytes.min(MAX_SAM_BYTES),
53        "SAM input",
54    )?;
55    let text = String::from_utf8(bytes)
56        .map_err(|error| Error::InvalidInput(format!("SAM input must be UTF-8/ASCII: {error}")))?;
57    let (mut table, warnings) = parse_sam(&text)?;
58    let mut page_sink = SamPageSink {
59        inner: sink,
60        warnings: &warnings,
61    };
62    convert_table_pages(&mut table, "sam", options, &mut page_sink)?;
63    Ok(warnings)
64}
65
66struct SamPageSink<'a> {
67    inner: &'a mut dyn PageConsumer,
68    warnings: &'a [String],
69}
70impl PageConsumer for SamPageSink<'_> {
71    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
72        page.source_format = "sam".into();
73        page.title = "SAM alignment records".into();
74        page.description = "SAM alignment columns are displayed inertly; no reference or alignment analysis is performed".into();
75        for warning in self.warnings {
76            page.warn(warning.clone());
77        }
78        self.inner.consume(page)
79    }
80}
81
82fn parse_sam(text: &str) -> Result<(TableData, Vec<String>)> {
83    if text.len() as u64 > MAX_SAM_BYTES {
84        return Err(Error::LimitExceeded(format!(
85            "SAM input exceeds {MAX_SAM_BYTES} bytes"
86        )));
87    }
88    let lines = text.lines().collect::<Vec<_>>();
89    if lines.len() > MAX_SAM_LINES {
90        return Err(Error::LimitExceeded(format!(
91            "SAM input exceeds {MAX_SAM_LINES} lines"
92        )));
93    }
94    let mut header = vec![
95        "QNAME", "FLAG", "RNAME", "POS", "MAPQ", "CIGAR", "RNEXT", "PNEXT", "TLEN", "SEQ", "QUAL",
96        "OPTIONAL",
97    ]
98    .into_iter()
99    .map(str::to_owned)
100    .collect::<Vec<_>>();
101    let mut rows = Vec::new();
102    let mut warnings = Vec::new();
103    let mut metadata = false;
104    let mut records = 0usize;
105    let mut cells = 0usize;
106    for (line_number, original) in lines.iter().enumerate() {
107        if original.len() > MAX_SAM_LINE_BYTES {
108            return Err(Error::LimitExceeded(format!(
109                "SAM line {} exceeds {MAX_SAM_LINE_BYTES} bytes",
110                line_number + 1
111            )));
112        }
113        let line = original.trim_end_matches('\r');
114        if line.is_empty() {
115            continue;
116        }
117        if line.starts_with('@') {
118            metadata = true;
119            if line.starts_with("@CO\t") && line.to_ascii_lowercase().contains("http") {
120                warnings.push(
121                    "SAM header comments containing URLs were retained as inert metadata".into(),
122                );
123            }
124            continue;
125        }
126        let fields = line.split('\t').collect::<Vec<_>>();
127        if fields.len() < 11 || fields.len() > MAX_SAM_COLUMNS {
128            return Err(Error::InvalidInput(format!(
129                "SAM alignment line {} must contain 11–{} tab-separated columns",
130                line_number + 1,
131                MAX_SAM_COLUMNS
132            )));
133        }
134        records += 1;
135        if records > MAX_SAM_RECORDS {
136            return Err(Error::LimitExceeded(format!(
137                "SAM exceeds {MAX_SAM_RECORDS} alignment records"
138            )));
139        }
140        if fields[0].is_empty() || fields[0].chars().any(char::is_whitespace) {
141            return Err(Error::InvalidInput(format!(
142                "SAM line {} QNAME is invalid",
143                line_number + 1
144            )));
145        }
146        let _flag = fields[1].parse::<u16>().map_err(|_| {
147            Error::InvalidInput(format!("SAM line {} FLAG is invalid", line_number + 1))
148        })?;
149        let pos = fields[3].parse::<u64>().map_err(|_| {
150            Error::InvalidInput(format!("SAM line {} POS is invalid", line_number + 1))
151        })?;
152        if fields[2] != "*" && fields[2].is_empty() {
153            return Err(Error::InvalidInput(format!(
154                "SAM line {} RNAME is invalid",
155                line_number + 1
156            )));
157        }
158        if fields[4] != "*" {
159            let mapq = fields[4].parse::<u16>().map_err(|_| {
160                Error::InvalidInput(format!("SAM line {} MAPQ is invalid", line_number + 1))
161            })?;
162            if mapq > 255 {
163                return Err(Error::InvalidInput(format!(
164                    "SAM line {} MAPQ exceeds 255",
165                    line_number + 1
166                )));
167            }
168        }
169        validate_cigar(fields[5], line_number + 1)?;
170        if fields[7] != "*" {
171            let _ = fields[7].parse::<u64>().map_err(|_| {
172                Error::InvalidInput(format!("SAM line {} PNEXT is invalid", line_number + 1))
173            })?;
174        }
175        if fields[8] != "*" {
176            let _ = fields[8].parse::<i64>().map_err(|_| {
177                Error::InvalidInput(format!("SAM line {} TLEN is invalid", line_number + 1))
178            })?;
179        }
180        if fields[9] != "*" {
181            validate_sequence(fields[9], "SAM SEQ", line_number + 1)?;
182            if fields[10] != "*" && fields[10].len() != fields[9].len() {
183                return Err(Error::InvalidInput(format!(
184                    "SAM line {} QUAL length does not match SEQ",
185                    line_number + 1
186                )));
187            }
188        } else if fields[10] != "*" {
189            return Err(Error::InvalidInput(format!(
190                "SAM line {} QUAL is present while SEQ is '* '",
191                line_number + 1
192            )));
193        }
194        let mut seen_tags = HashSet::new();
195        for tag in fields.iter().skip(11) {
196            validate_tag(tag, line_number + 1, &mut seen_tags)?;
197        }
198        for field in &fields {
199            if field.len() > MAX_SAM_VALUE_BYTES {
200                return Err(Error::LimitExceeded(format!(
201                    "SAM line {} field exceeds {MAX_SAM_VALUE_BYTES} bytes",
202                    line_number + 1
203                )));
204            }
205            if field.chars().any(char::is_control) {
206                return Err(Error::InvalidInput(format!(
207                    "SAM line {} contains a control character",
208                    line_number + 1
209                )));
210            }
211        }
212        cells = cells
213            .checked_add(fields.len())
214            .ok_or_else(|| Error::LimitExceeded("SAM cell count overflowed".into()))?;
215        if cells > MAX_SAM_CELLS {
216            return Err(Error::LimitExceeded(format!(
217                "SAM exceeds {MAX_SAM_CELLS} cells"
218            )));
219        }
220        let optional = fields
221            .iter()
222            .skip(11)
223            .copied()
224            .collect::<Vec<_>>()
225            .join(" ");
226        let mut row = fields[..11]
227            .iter()
228            .map(|field| (*field).to_owned())
229            .collect::<Vec<_>>();
230        row.push(optional);
231        rows.push(row);
232        let _ = pos;
233    }
234    if rows.is_empty() {
235        return Err(Error::InvalidInput(
236            "SAM contains no alignment records".into(),
237        ));
238    }
239    if metadata {
240        warnings.push("SAM header metadata was ignored for rendering".into());
241    }
242    let alignments = (0..header.len())
243        .map(|index| {
244            if matches!(index, 1 | 3 | 4 | 7 | 8) {
245                TableAlign::Right
246            } else {
247                TableAlign::Left
248            }
249        })
250        .collect();
251    Ok((
252        TableData {
253            headers: std::mem::take(&mut header),
254            rows,
255            alignments,
256            raw_source: String::new(),
257        },
258        dedup_warnings(warnings),
259    ))
260}
261
262fn validate_sequence(sequence: &str, context: &str, line: usize) -> Result<()> {
263    if sequence
264        .bytes()
265        .any(|byte| !byte.is_ascii_alphabetic() && byte != b'=' && byte != b'.')
266    {
267        return Err(Error::InvalidInput(format!(
268            "{context} on line {line} contains an invalid base"
269        )));
270    }
271    Ok(())
272}
273
274fn validate_cigar(cigar: &str, line: usize) -> Result<()> {
275    if cigar == "*" {
276        return Ok(());
277    }
278    let mut digits = 0usize;
279    let mut operators = 0usize;
280    for byte in cigar.bytes() {
281        if byte.is_ascii_digit() {
282            digits += 1;
283            continue;
284        }
285        if digits == 0
286            || !matches!(
287                byte,
288                b'M' | b'I' | b'D' | b'N' | b'S' | b'H' | b'P' | b'=' | b'X'
289            )
290        {
291            return Err(Error::InvalidInput(format!(
292                "SAM line {line} CIGAR is invalid"
293            )));
294        }
295        operators += 1;
296        if operators > 65_535 {
297            return Err(Error::LimitExceeded(format!(
298                "SAM line {line} CIGAR has too many operators"
299            )));
300        }
301        digits = 0;
302    }
303    if digits != 0 || operators == 0 {
304        return Err(Error::InvalidInput(format!(
305            "SAM line {line} CIGAR is incomplete"
306        )));
307    }
308    Ok(())
309}
310
311fn validate_tag(tag: &str, line: usize, seen: &mut HashSet<String>) -> Result<()> {
312    let mut parts = tag.splitn(3, ':');
313    let name = parts.next().unwrap_or_default();
314    let kind = parts.next().unwrap_or_default();
315    let value = parts.next().unwrap_or_default();
316    if name.len() != 2
317        || !name
318            .bytes()
319            .next()
320            .is_some_and(|byte| byte.is_ascii_alphabetic())
321        || !name
322            .as_bytes()
323            .get(1)
324            .copied()
325            .is_some_and(|byte| byte.is_ascii_alphanumeric())
326        || kind.len() != 1
327        || value.is_empty()
328        || !seen.insert(name.to_owned())
329    {
330        return Err(Error::InvalidInput(format!(
331            "SAM line {line} optional tag is invalid or duplicated"
332        )));
333    }
334    Ok(())
335}
336
337fn dedup_warnings(warnings: Vec<String>) -> Vec<String> {
338    let mut seen = HashSet::new();
339    warnings
340        .into_iter()
341        .filter(|warning| seen.insert(warning.clone()))
342        .collect()
343}