1use std::borrow::Cow;
12use std::collections::BTreeSet;
13use std::path::Path;
14
15use object::FileKind;
16
17use crate::models::ScanDiagnostic;
18use crate::parsers::windows_executable::extract_windows_executable_metadata_text;
19use crate::utils::font::{extract_font_metadata_text, extract_font_name_table_strings};
20use crate::utils::language::detect_language;
21
22use super::encoding::{
23 decode_bytes_to_string_with_diagnostic, looks_like_decoded_text, looks_like_textual_bytes,
24};
25use super::format_sniff::{
26 detect_file_format, is_supported_image_container, is_textual_format, is_zip_archive,
27 looks_like_bzip2, looks_like_deb, looks_like_gzip, looks_like_pdf, looks_like_rpm,
28 looks_like_rtf, looks_like_squashfs, looks_like_xz, media_mime_from_content,
29 supported_image_metadata_format,
30};
31use super::image_metadata::extract_image_metadata_text;
32use super::path::{PLAIN_TEXT_EXTENSIONS, lower_extension};
33use super::pdf::extract_pdf_text;
34
35pub(super) const LARGE_OPAQUE_BINARY_SKIP_BYTES: usize = 512 * 1024;
36const LARGE_MACHO_LEGAL_WINDOW_BYTES: usize = 64 * 1024;
37const LARGE_MACHO_LEGAL_MAX_WINDOWS: usize = 24;
38const LARGE_MACHO_LEGAL_MAX_WINDOWS_PER_MARKER: usize = 4;
39const LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES: usize = 2 * 1024 * 1024;
40const LARGE_MACHO_LEGAL_MARKERS: &[&[u8]] = &[
41 b"Unicode, Inc.",
42 b"http://www.unicode.org/copyright.html",
43 b"https://www.unicode.org/copyright.html",
44 b"SPDX-License-Identifier:",
45 b"Licensed under",
46 b"licensed under",
47 b"Apache License",
48 b"http://www.apache.org/licenses/",
49 b"https://www.apache.org/licenses/",
50 b"Permission is hereby granted",
51 b"permission is hereby granted",
52 b"Redistribution and use in source and binary forms",
53 b"redistribution and use in source and binary forms",
54 b"Permission to use, copy, modify, and/or distribute this software",
55 b"The MIT License",
56 b"GNU GENERAL PUBLIC LICENSE",
57 b"GNU LESSER GENERAL PUBLIC LICENSE",
58 b"Mozilla Public License",
59];
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ExtractedTextKind {
63 None,
64 Decoded,
65 FontMetadata,
66 Pdf,
67 BinaryStrings,
68 ImageMetadata,
69 WindowsExecutableMetadata,
70}
71
72pub fn extract_text_for_detection(path: &Path, bytes: &[u8]) -> (String, ExtractedTextKind) {
73 let (text, kind, _) = extract_text_for_detection_with_diagnostics(path, bytes);
74 (text, kind)
75}
76
77pub(crate) fn augment_license_detection_text<'a>(path: &Path, text: &'a str) -> Cow<'a, str> {
78 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
79 return Cow::Borrowed(text);
80 };
81 if !matches!(
82 extension.to_ascii_lowercase().as_str(),
83 "md" | "markdown" | "html" | "htm"
84 ) {
85 return Cow::Borrowed(text);
86 }
87
88 let mut hints = Vec::new();
89 let has_dual_license_notice = has_dual_license_notice_text(text);
90 if text.contains("CC BY 4.0") || text.contains("creativecommons.org/licenses/by/4.0") {
91 hints.push("Creative Commons Attribution 4.0 International License".to_string());
92 }
93 if !has_dual_license_notice
94 && (text.contains("Apache License (Version 2.0)")
95 || text.contains("Apache License, Version 2.0"))
96 {
97 hints.push(
98 "Licensed under the Apache License, Version 2.0. http://www.apache.org/licenses/LICENSE-2.0"
99 .to_string(),
100 );
101 }
102
103 if !has_dual_license_notice {
104 hints.extend(extract_shields_license_badge_hints(text));
105 }
106
107 if hints.is_empty() {
108 Cow::Borrowed(text)
109 } else {
110 let mut augmented =
111 String::with_capacity(text.len() + hints.iter().map(String::len).sum::<usize>() + 8);
112 augmented.push_str(text);
113 augmented.push_str("\n\n");
114 for (index, hint) in hints.into_iter().enumerate() {
115 if index > 0 {
116 augmented.push('\n');
117 }
118 augmented.push_str(&hint);
119 }
120 Cow::Owned(augmented)
121 }
122}
123
124fn extract_shields_license_badge_hints(text: &str) -> Vec<String> {
125 let mut hints = Vec::new();
126 let mut rest = text;
127 let needle = "img.shields.io/badge/license-";
128
129 while let Some(index) = rest.find(needle) {
130 let start = index + needle.len();
131 let suffix = &rest[start..];
132 let end = suffix
133 .find([')', ']', '"', '\'', ' ', '\n'])
134 .unwrap_or(suffix.len());
135 let badge = &suffix[..end];
136 let Some(badge) = badge.strip_suffix(".svg") else {
137 rest = &suffix[end..];
138 continue;
139 };
140
141 let mut segments: Vec<_> = badge
142 .split('-')
143 .filter(|segment| !segment.is_empty())
144 .collect();
145 if segments.len() < 2 {
146 rest = &suffix[end..];
147 continue;
148 }
149 segments.pop();
150 let candidate = segments.join("-").replace("%20", " ").replace('_', "-");
151 if !candidate.is_empty() {
152 hints.push(canonical_shields_license_hint(&candidate));
153 }
154
155 rest = &suffix[end..];
156 }
157
158 hints.sort();
159 hints.dedup();
160 hints
161}
162
163fn has_dual_license_notice_text(text: &str) -> bool {
164 let lower = text.to_ascii_lowercase();
165 (lower.contains("licensed under either of") && lower.contains("at your option"))
166 || lower.contains("dual-licensed under")
167 || lower.contains("dual licensed under")
168}
169
170fn canonical_shields_license_hint(candidate: &str) -> String {
171 match candidate.trim() {
172 "MIT" => "The MIT License".to_string(),
173 "Apache-2.0" | "Apache 2.0" => "Apache License 2.0".to_string(),
174 other => format!("{other} License"),
175 }
176}
177
178pub(crate) fn extract_text_for_detection_with_diagnostics(
179 path: &Path,
180 bytes: &[u8],
181) -> (String, ExtractedTextKind, Option<ScanDiagnostic>) {
182 let ext = path
183 .extension()
184 .and_then(|e| e.to_str())
185 .map(|s| s.to_ascii_lowercase());
186 let detected_format = detect_file_format(bytes);
187
188 if looks_like_rtf(bytes, ext.as_deref()) {
189 let text = extract_rtf_text(bytes);
190 return if text.trim().is_empty() {
191 (String::new(), ExtractedTextKind::None, None)
192 } else {
193 (text, ExtractedTextKind::Decoded, None)
194 };
195 }
196
197 if looks_like_pdf(bytes) || detected_format.short_name() == Some("PDF") {
198 let (text, scan_error) = extract_pdf_text(path, bytes);
199 return if text.is_empty() {
200 (String::new(), ExtractedTextKind::None, scan_error)
201 } else {
202 (text, ExtractedTextKind::Pdf, None)
203 };
204 }
205
206 if let Some(format) = supported_image_metadata_format(ext.as_deref(), detected_format) {
207 let text = extract_image_metadata_text(bytes, format);
208 return if text.is_empty() {
209 if is_supported_image_container(bytes, format) {
210 (String::new(), ExtractedTextKind::None, None)
211 } else {
212 let (decoded, decode_diagnostic) = decode_bytes_to_string_with_diagnostic(bytes);
213 if decoded.is_empty() {
214 (String::new(), ExtractedTextKind::None, decode_diagnostic)
215 } else {
216 (decoded, ExtractedTextKind::Decoded, None)
217 }
218 }
219 } else {
220 (text, ExtractedTextKind::ImageMetadata, None)
221 };
222 }
223
224 if let Some(text) = extract_font_metadata_text(path, bytes) {
225 let name_strings = extract_font_name_table_strings(bytes);
230 let combined = if name_strings.is_empty() {
231 text
232 } else {
233 combine_extracted_text_fragments(Some(text), name_strings)
234 };
235 return (combined, ExtractedTextKind::FontMetadata, None);
236 }
237
238 let windows_executable_metadata_text = extract_windows_executable_metadata_text(bytes);
239 let large_opaque_binary = windows_executable_metadata_text.is_none()
240 && is_large_opaque_binary_candidate(bytes, detected_format);
241 let bounded_macho_legal_text = if large_opaque_binary {
242 extract_bounded_macho_legal_strings(bytes)
243 } else {
244 String::new()
245 };
246 let skip_large_opaque_binary_text =
247 should_skip_large_opaque_binary_text_extraction(path, bytes, detected_format);
248
249 if skip_large_opaque_binary_text {
250 if !bounded_macho_legal_text.is_empty() {
251 return (
252 combine_extracted_text_fragments(
253 windows_executable_metadata_text,
254 bounded_macho_legal_text,
255 ),
256 ExtractedTextKind::BinaryStrings,
257 None,
258 );
259 }
260 return windows_metadata_or_empty_result(windows_executable_metadata_text);
261 }
262
263 if should_skip_binary_string_extraction(path, bytes, detected_format) {
264 return (String::new(), ExtractedTextKind::None, None);
265 }
266
267 let is_svg_text = lower_extension(path).as_deref() == Some("svg")
268 || detected_format.media_type() == "image/svg+xml";
269 let should_try_decoded_text = looks_like_textual_bytes(bytes) || is_svg_text;
270 let decoded_is_utf8 = std::str::from_utf8(bytes).is_ok();
271 let path_suggests_text = ext.as_deref().is_some_and(|extension| {
272 PLAIN_TEXT_EXTENSIONS.contains(&extension) || detect_language(path, bytes).is_some()
273 });
274
275 let mut decode_diagnostic = None;
276 if !large_opaque_binary && should_try_decoded_text {
277 let (decoded, diagnostic) = decode_bytes_to_string_with_diagnostic(bytes);
278 decode_diagnostic = diagnostic;
279 if !decoded.is_empty()
280 && (is_svg_text
281 || decoded_is_utf8
282 || path_suggests_text
283 || looks_like_decoded_text(&decoded))
284 {
285 let combined =
286 combine_extracted_text_fragments(windows_executable_metadata_text, decoded);
287 return (combined, ExtractedTextKind::Decoded, None);
288 }
289 }
290
291 let text = if large_opaque_binary {
292 let sampled_text = extract_sampled_printable_strings(bytes);
293 if bounded_macho_legal_text.is_empty() {
294 sampled_text
295 } else {
296 combine_extracted_text_fragments(Some(sampled_text), bounded_macho_legal_text)
297 }
298 } else {
299 extract_printable_strings(bytes)
300 };
301 if text.is_empty() {
302 let (result_text, result_kind, result_diagnostic) =
303 windows_metadata_or_empty_result(windows_executable_metadata_text);
304 let result_diagnostic = if result_text.is_empty() {
307 result_diagnostic.or(decode_diagnostic)
308 } else {
309 result_diagnostic
310 };
311 (result_text, result_kind, result_diagnostic)
312 } else {
313 (
314 combine_extracted_text_fragments(windows_executable_metadata_text, text),
315 ExtractedTextKind::BinaryStrings,
316 None,
317 )
318 }
319}
320
321fn combine_extracted_text_fragments(prefix: Option<String>, suffix: String) -> String {
322 match prefix {
323 Some(prefix) if !prefix.is_empty() && !suffix.is_empty() => format!("{prefix}\n{suffix}"),
324 Some(prefix) if !prefix.is_empty() => prefix,
325 _ => suffix,
326 }
327}
328
329pub(super) fn windows_metadata_or_empty_result(
330 windows_executable_metadata_text: Option<String>,
331) -> (String, ExtractedTextKind, Option<ScanDiagnostic>) {
332 if let Some(metadata_text) = windows_executable_metadata_text {
333 (
334 metadata_text,
335 ExtractedTextKind::WindowsExecutableMetadata,
336 None,
337 )
338 } else {
339 (String::new(), ExtractedTextKind::None, None)
340 }
341}
342
343fn extract_rtf_text(bytes: &[u8]) -> String {
344 let text = String::from_utf8_lossy(bytes);
345 let chars: Vec<char> = text.chars().collect();
346 let mut output = String::new();
347 let mut index = 0usize;
348
349 while index < chars.len() {
350 match chars[index] {
351 '{' | '}' => {
352 index += 1;
353 }
354 '\\' => {
355 index += 1;
356 if index >= chars.len() {
357 break;
358 }
359
360 match chars[index] {
361 '\\' | '{' | '}' => {
362 output.push(chars[index]);
363 index += 1;
364 }
365 '\'' => {
366 if index + 2 < chars.len() {
367 let hex = [chars[index + 1], chars[index + 2]];
368 let hex: String = hex.iter().collect();
369 if let Ok(value) = u8::from_str_radix(&hex, 16) {
370 output.push(value as char);
371 index += 3;
372 continue;
373 }
374 }
375 index += 1;
376 }
377 control if control.is_ascii_alphabetic() => {
378 let start = index;
379 while index < chars.len() && chars[index].is_ascii_alphabetic() {
380 index += 1;
381 }
382 let control_word: String = chars[start..index].iter().collect();
383
384 let number_start = index;
385 if index < chars.len()
386 && (chars[index] == '-' || chars[index].is_ascii_digit())
387 {
388 index += 1;
389 while index < chars.len() && chars[index].is_ascii_digit() {
390 index += 1;
391 }
392 }
393 let parameter: String = chars[number_start..index].iter().collect();
394
395 if index < chars.len() && chars[index] == ' ' {
396 index += 1;
397 }
398
399 match control_word.as_str() {
400 "par" | "line" => output.push('\n'),
401 "tab" => output.push('\t'),
402 "emdash" => output.push('—'),
403 "endash" => output.push('–'),
404 "bullet" => output.push('•'),
405 "lquote" | "rquote" => output.push('\''),
406 "ldblquote" | "rdblquote" => output.push('"'),
407 "u" => {
408 if let Ok(codepoint) = parameter.parse::<i32>() {
409 let normalized = if codepoint < 0 {
410 codepoint + 65_536
411 } else {
412 codepoint
413 };
414 if let Ok(normalized) = u32::try_from(normalized)
415 && let Some(ch) = char::from_u32(normalized)
416 {
417 output.push(ch);
418 }
419 }
420
421 if index < chars.len()
422 && !matches!(chars[index], '\\' | '{' | '}' | '\n' | '\r')
423 {
424 index += 1;
425 }
426 }
427 _ => {}
428 }
429 }
430 _ => {
431 index += 1;
432 }
433 }
434 }
435 ch => {
436 output.push(ch);
437 index += 1;
438 }
439 }
440 }
441
442 output
443 .replace(['\r', '\u{0c}'], "\n")
444 .lines()
445 .map(str::trim_end)
446 .collect::<Vec<_>>()
447 .join("\n")
448}
449
450fn should_skip_binary_string_extraction(
451 path: &Path,
452 bytes: &[u8],
453 detected_format: file_format::FileFormat,
454) -> bool {
455 use file_format::Kind as FileFormatKind;
456 matches!(lower_extension(path).as_deref(), Some("pdf"))
457 || supported_image_metadata_format(lower_extension(path).as_deref(), detected_format)
458 .is_some()
459 || (matches!(
460 detected_format.kind(),
461 FileFormatKind::Audio | FileFormatKind::Image | FileFormatKind::Video
462 ) && !is_textual_format(detected_format))
463 || media_mime_from_content(bytes).is_some()
464 || is_zip_archive(bytes)
465 || looks_like_gzip(bytes)
466 || looks_like_bzip2(bytes)
467 || looks_like_xz(bytes)
468 || looks_like_deb(bytes, path)
469 || looks_like_rpm(bytes, path)
470 || looks_like_squashfs(bytes, path)
471}
472
473fn should_skip_large_opaque_binary_text_extraction(
474 _path: &Path,
475 bytes: &[u8],
476 detected_format: file_format::FileFormat,
477) -> bool {
478 is_large_opaque_binary_candidate(bytes, detected_format)
479 && !sample_has_promising_printable_strings(bytes)
480}
481
482fn is_large_opaque_binary_candidate(
483 bytes: &[u8],
484 detected_format: file_format::FileFormat,
485) -> bool {
486 use file_format::Kind as FileFormatKind;
487 bytes.len() >= LARGE_OPAQUE_BINARY_SKIP_BYTES
488 && !is_textual_format(detected_format)
489 && !matches!(
490 detected_format.kind(),
491 FileFormatKind::Archive
492 | FileFormatKind::Compressed
493 | FileFormatKind::Package
494 | FileFormatKind::Audio
495 | FileFormatKind::Image
496 | FileFormatKind::Video
497 )
498}
499
500fn sampled_printable_window_ranges(len: usize) -> Vec<(usize, usize)> {
501 const SAMPLE_WINDOW_BYTES: usize = 64 * 1024;
502
503 let mut ranges = Vec::new();
504 let mut push_range = |start: usize, end: usize| {
505 if start < end && !ranges.contains(&(start, end)) {
506 ranges.push((start, end));
507 }
508 };
509
510 push_range(0, len.min(SAMPLE_WINDOW_BYTES));
511 if len > SAMPLE_WINDOW_BYTES * 2 {
512 let mid_start = len / 2 - SAMPLE_WINDOW_BYTES / 2;
513 let mid_end = (mid_start + SAMPLE_WINDOW_BYTES).min(len);
514 push_range(mid_start, mid_end);
515 }
516 if len > SAMPLE_WINDOW_BYTES {
517 push_range(len - SAMPLE_WINDOW_BYTES, len);
518 }
519
520 ranges
521}
522
523fn extract_bounded_macho_legal_strings(bytes: &[u8]) -> String {
524 if !matches!(
525 FileKind::parse(bytes),
526 Ok(FileKind::MachO32 | FileKind::MachO64 | FileKind::MachOFat32 | FileKind::MachOFat64)
527 ) {
528 return String::new();
529 }
530
531 let mut ranges = Vec::new();
532 for marker in LARGE_MACHO_LEGAL_MARKERS {
533 collect_marker_window_ranges(bytes, marker, &mut ranges);
534 if ranges.len() >= LARGE_MACHO_LEGAL_MAX_WINDOWS {
535 break;
536 }
537 }
538
539 if ranges.is_empty() {
540 return String::new();
541 }
542
543 let mut merged_ranges = merge_overlapping_ranges(ranges);
544 let mut combined_lines = BTreeSet::new();
545 let mut extracted_bytes = 0usize;
546
547 for (start, end) in merged_ranges.drain(..) {
548 if extracted_bytes >= LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES {
549 break;
550 }
551 let remaining = LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES - extracted_bytes;
552 let end = start.saturating_add((end - start).min(remaining));
553 let window_text = extract_printable_strings(&bytes[start..end]);
554 for line in window_text
555 .lines()
556 .map(str::trim)
557 .filter(|line| !line.is_empty())
558 {
559 combined_lines.insert(line.to_string());
560 }
561 extracted_bytes += end - start;
562 }
563
564 combined_lines.into_iter().collect::<Vec<_>>().join("\n")
565}
566
567fn collect_marker_window_ranges(bytes: &[u8], marker: &[u8], ranges: &mut Vec<(usize, usize)>) {
568 if marker.is_empty() || ranges.len() >= LARGE_MACHO_LEGAL_MAX_WINDOWS {
569 return;
570 }
571
572 let mut search_start = 0usize;
573 let mut hits_for_marker = 0usize;
574
575 while search_start + marker.len() <= bytes.len()
576 && ranges.len() < LARGE_MACHO_LEGAL_MAX_WINDOWS
577 && hits_for_marker < LARGE_MACHO_LEGAL_MAX_WINDOWS_PER_MARKER
578 {
579 let Some(relative_match) = bytes[search_start..].iter().position(|&b| b == marker[0])
580 else {
581 break;
582 };
583 let match_start = search_start + relative_match;
584 let match_end = match_start + marker.len();
585 if match_end <= bytes.len() && &bytes[match_start..match_end] == marker {
586 let half_window = LARGE_MACHO_LEGAL_WINDOW_BYTES / 2;
587 let window_start = match_start.saturating_sub(half_window);
588 let window_end = (match_end + half_window).min(bytes.len());
589 ranges.push((window_start, window_end));
590 hits_for_marker += 1;
591 search_start = match_end;
592 } else {
593 search_start = match_start + 1;
594 }
595 }
596}
597
598fn merge_overlapping_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
599 if ranges.is_empty() {
600 return ranges;
601 }
602
603 ranges.sort_unstable_by_key(|&(start, end)| (start, end));
604
605 let mut merged = Vec::with_capacity(ranges.len());
606 let mut current = ranges[0];
607 for (start, end) in ranges.into_iter().skip(1) {
608 if start <= current.1 {
609 current.1 = current.1.max(end);
610 } else {
611 merged.push(current);
612 current = (start, end);
613 }
614 }
615 merged.push(current);
616
617 merged
618}
619
620fn sample_has_promising_printable_strings(bytes: &[u8]) -> bool {
621 let mut structured_signal_seen = false;
622 let promising_license_windows = sampled_printable_window_ranges(bytes.len())
623 .into_iter()
624 .filter(|&(start, end)| {
625 let window = &bytes[start..end];
626 if has_strong_structured_text_signal(window) {
627 structured_signal_seen = true;
628 }
629 has_license_or_notice_signal(window)
630 })
631 .count();
632
633 structured_signal_seen || promising_license_windows >= 2
634}
635
636fn extract_sampled_printable_strings(bytes: &[u8]) -> String {
637 let mut combined_lines = BTreeSet::new();
638
639 for (start, end) in sampled_printable_window_ranges(bytes.len()) {
640 let window_text = extract_printable_strings(&bytes[start..end]);
641 for line in window_text
642 .lines()
643 .map(str::trim)
644 .filter(|line| !line.is_empty())
645 {
646 combined_lines.insert(line.to_string());
647 }
648 }
649
650 combined_lines.into_iter().collect::<Vec<_>>().join("\n")
651}
652
653fn has_license_or_notice_signal(bytes: &[u8]) -> bool {
654 let strings = extract_printable_strings(bytes);
655 if strings.is_empty() {
656 return false;
657 }
658
659 let lower = strings.to_ascii_lowercase();
660 [
661 "copyright",
662 "license",
663 "licensed under",
664 "all rights reserved",
665 "permission is hereby granted",
666 "redistribution and use",
667 "spdx-license-identifier",
668 ]
669 .iter()
670 .any(|marker| lower.contains(marker))
671}
672
673fn has_strong_structured_text_signal(bytes: &[u8]) -> bool {
674 let strings = extract_printable_strings(bytes);
675 if strings.is_empty() {
676 return false;
677 }
678
679 let email_markers = strings.matches('@').count();
680 let url_markers = strings.matches("http://").count() + strings.matches("https://").count();
681
682 email_markers + url_markers >= 3
683}
684
685pub fn extract_printable_strings(bytes: &[u8]) -> String {
686 const MIN_LEN: usize = 4;
687 const MIN_OUTPUT_BYTES: usize = 2_000_000;
688 const MAX_OUTPUT_BYTES_CAP: usize = 16_000_000;
689
690 let max_output_bytes = bytes.len().clamp(MIN_OUTPUT_BYTES, MAX_OUTPUT_BYTES_CAP);
691
692 fn is_printable_ascii(b: u8) -> bool {
693 matches!(b, 0x20..=0x7E)
694 }
695
696 let mut out = String::new();
697 let mut run: Vec<u8> = Vec::new();
698
699 let flush_run = |out: &mut String, run: &mut Vec<u8>| {
700 if run.len() >= MIN_LEN {
701 if !out.is_empty() {
702 out.push('\n');
703 }
704 out.push_str(&String::from_utf8_lossy(run));
705 }
706 run.clear();
707 };
708
709 for &b in bytes {
710 if is_printable_ascii(b) {
711 run.push(b);
712 } else {
713 flush_run(&mut out, &mut run);
714 if out.len() >= max_output_bytes {
715 return out;
716 }
717 }
718 }
719 flush_run(&mut out, &mut run);
720 if out.len() >= max_output_bytes {
721 return out;
722 }
723
724 for start in 0..=1 {
725 run.clear();
726 let mut i = start;
727 while i + 1 < bytes.len() {
728 let b0 = bytes[i];
729 let b1 = bytes[i + 1];
730 let (ch, zero) = if start == 0 { (b0, b1) } else { (b1, b0) };
731 if is_printable_ascii(ch) && zero == 0 {
732 run.push(ch);
733 } else {
734 flush_run(&mut out, &mut run);
735 if out.len() >= max_output_bytes {
736 return out;
737 }
738 }
739 i += 2;
740 }
741 flush_run(&mut out, &mut run);
742 if out.len() >= max_output_bytes {
743 return out;
744 }
745 }
746
747 out
748}