document_svg/document/
sam.rs1use 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}