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;
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 strings = extract_printable_strings(bytes);
226 let combined = if strings.is_empty() {
227 text
228 } else {
229 combine_extracted_text_fragments(Some(text), strings)
230 };
231 return (combined, ExtractedTextKind::FontMetadata, None);
232 }
233
234 let windows_executable_metadata_text = extract_windows_executable_metadata_text(bytes);
235 let large_opaque_binary = windows_executable_metadata_text.is_none()
236 && is_large_opaque_binary_candidate(bytes, detected_format);
237 let bounded_macho_legal_text = if large_opaque_binary {
238 extract_bounded_macho_legal_strings(bytes)
239 } else {
240 String::new()
241 };
242 let skip_large_opaque_binary_text =
243 should_skip_large_opaque_binary_text_extraction(path, bytes, detected_format);
244
245 if skip_large_opaque_binary_text {
246 if !bounded_macho_legal_text.is_empty() {
247 return (
248 combine_extracted_text_fragments(
249 windows_executable_metadata_text,
250 bounded_macho_legal_text,
251 ),
252 ExtractedTextKind::BinaryStrings,
253 None,
254 );
255 }
256 return windows_metadata_or_empty_result(windows_executable_metadata_text);
257 }
258
259 if should_skip_binary_string_extraction(path, bytes, detected_format) {
260 return (String::new(), ExtractedTextKind::None, None);
261 }
262
263 let is_svg_text = lower_extension(path).as_deref() == Some("svg")
264 || detected_format.media_type() == "image/svg+xml";
265 let should_try_decoded_text = looks_like_textual_bytes(bytes) || is_svg_text;
266 let decoded_is_utf8 = std::str::from_utf8(bytes).is_ok();
267 let path_suggests_text = ext.as_deref().is_some_and(|extension| {
268 PLAIN_TEXT_EXTENSIONS.contains(&extension) || detect_language(path, bytes).is_some()
269 });
270
271 let mut decode_diagnostic = None;
272 if !large_opaque_binary && should_try_decoded_text {
273 let (decoded, diagnostic) = decode_bytes_to_string_with_diagnostic(bytes);
274 decode_diagnostic = diagnostic;
275 if !decoded.is_empty()
276 && (is_svg_text
277 || decoded_is_utf8
278 || path_suggests_text
279 || looks_like_decoded_text(&decoded))
280 {
281 let combined =
282 combine_extracted_text_fragments(windows_executable_metadata_text, decoded);
283 return (combined, ExtractedTextKind::Decoded, None);
284 }
285 }
286
287 let text = if large_opaque_binary {
288 let sampled_text = extract_sampled_printable_strings(bytes);
289 if bounded_macho_legal_text.is_empty() {
290 sampled_text
291 } else {
292 combine_extracted_text_fragments(Some(sampled_text), bounded_macho_legal_text)
293 }
294 } else {
295 extract_printable_strings(bytes)
296 };
297 if text.is_empty() {
298 let (result_text, result_kind, result_diagnostic) =
299 windows_metadata_or_empty_result(windows_executable_metadata_text);
300 let result_diagnostic = if result_text.is_empty() {
303 result_diagnostic.or(decode_diagnostic)
304 } else {
305 result_diagnostic
306 };
307 (result_text, result_kind, result_diagnostic)
308 } else {
309 (
310 combine_extracted_text_fragments(windows_executable_metadata_text, text),
311 ExtractedTextKind::BinaryStrings,
312 None,
313 )
314 }
315}
316
317fn combine_extracted_text_fragments(prefix: Option<String>, suffix: String) -> String {
318 match prefix {
319 Some(prefix) if !prefix.is_empty() && !suffix.is_empty() => format!("{prefix}\n{suffix}"),
320 Some(prefix) if !prefix.is_empty() => prefix,
321 _ => suffix,
322 }
323}
324
325pub(super) fn windows_metadata_or_empty_result(
326 windows_executable_metadata_text: Option<String>,
327) -> (String, ExtractedTextKind, Option<ScanDiagnostic>) {
328 if let Some(metadata_text) = windows_executable_metadata_text {
329 (
330 metadata_text,
331 ExtractedTextKind::WindowsExecutableMetadata,
332 None,
333 )
334 } else {
335 (String::new(), ExtractedTextKind::None, None)
336 }
337}
338
339fn extract_rtf_text(bytes: &[u8]) -> String {
340 let text = String::from_utf8_lossy(bytes);
341 let chars: Vec<char> = text.chars().collect();
342 let mut output = String::new();
343 let mut index = 0usize;
344
345 while index < chars.len() {
346 match chars[index] {
347 '{' | '}' => {
348 index += 1;
349 }
350 '\\' => {
351 index += 1;
352 if index >= chars.len() {
353 break;
354 }
355
356 match chars[index] {
357 '\\' | '{' | '}' => {
358 output.push(chars[index]);
359 index += 1;
360 }
361 '\'' => {
362 if index + 2 < chars.len() {
363 let hex = [chars[index + 1], chars[index + 2]];
364 let hex: String = hex.iter().collect();
365 if let Ok(value) = u8::from_str_radix(&hex, 16) {
366 output.push(value as char);
367 index += 3;
368 continue;
369 }
370 }
371 index += 1;
372 }
373 control if control.is_ascii_alphabetic() => {
374 let start = index;
375 while index < chars.len() && chars[index].is_ascii_alphabetic() {
376 index += 1;
377 }
378 let control_word: String = chars[start..index].iter().collect();
379
380 let number_start = index;
381 if index < chars.len()
382 && (chars[index] == '-' || chars[index].is_ascii_digit())
383 {
384 index += 1;
385 while index < chars.len() && chars[index].is_ascii_digit() {
386 index += 1;
387 }
388 }
389 let parameter: String = chars[number_start..index].iter().collect();
390
391 if index < chars.len() && chars[index] == ' ' {
392 index += 1;
393 }
394
395 match control_word.as_str() {
396 "par" | "line" => output.push('\n'),
397 "tab" => output.push('\t'),
398 "emdash" => output.push('—'),
399 "endash" => output.push('–'),
400 "bullet" => output.push('•'),
401 "lquote" | "rquote" => output.push('\''),
402 "ldblquote" | "rdblquote" => output.push('"'),
403 "u" => {
404 if let Ok(codepoint) = parameter.parse::<i32>() {
405 let normalized = if codepoint < 0 {
406 codepoint + 65_536
407 } else {
408 codepoint
409 };
410 if let Ok(normalized) = u32::try_from(normalized)
411 && let Some(ch) = char::from_u32(normalized)
412 {
413 output.push(ch);
414 }
415 }
416
417 if index < chars.len()
418 && !matches!(chars[index], '\\' | '{' | '}' | '\n' | '\r')
419 {
420 index += 1;
421 }
422 }
423 _ => {}
424 }
425 }
426 _ => {
427 index += 1;
428 }
429 }
430 }
431 ch => {
432 output.push(ch);
433 index += 1;
434 }
435 }
436 }
437
438 output
439 .replace(['\r', '\u{0c}'], "\n")
440 .lines()
441 .map(str::trim_end)
442 .collect::<Vec<_>>()
443 .join("\n")
444}
445
446fn should_skip_binary_string_extraction(
447 path: &Path,
448 bytes: &[u8],
449 detected_format: file_format::FileFormat,
450) -> bool {
451 use file_format::Kind as FileFormatKind;
452 matches!(lower_extension(path).as_deref(), Some("pdf"))
453 || supported_image_metadata_format(lower_extension(path).as_deref(), detected_format)
454 .is_some()
455 || (matches!(
456 detected_format.kind(),
457 FileFormatKind::Audio | FileFormatKind::Image | FileFormatKind::Video
458 ) && !is_textual_format(detected_format))
459 || media_mime_from_content(bytes).is_some()
460 || is_zip_archive(bytes)
461 || looks_like_gzip(bytes)
462 || looks_like_bzip2(bytes)
463 || looks_like_xz(bytes)
464 || looks_like_deb(bytes, path)
465 || looks_like_rpm(bytes, path)
466 || looks_like_squashfs(bytes, path)
467}
468
469fn should_skip_large_opaque_binary_text_extraction(
470 _path: &Path,
471 bytes: &[u8],
472 detected_format: file_format::FileFormat,
473) -> bool {
474 is_large_opaque_binary_candidate(bytes, detected_format)
475 && !sample_has_promising_printable_strings(bytes)
476}
477
478fn is_large_opaque_binary_candidate(
479 bytes: &[u8],
480 detected_format: file_format::FileFormat,
481) -> bool {
482 use file_format::Kind as FileFormatKind;
483 bytes.len() >= LARGE_OPAQUE_BINARY_SKIP_BYTES
484 && !is_textual_format(detected_format)
485 && !matches!(
486 detected_format.kind(),
487 FileFormatKind::Archive
488 | FileFormatKind::Compressed
489 | FileFormatKind::Package
490 | FileFormatKind::Audio
491 | FileFormatKind::Image
492 | FileFormatKind::Video
493 )
494}
495
496fn sampled_printable_window_ranges(len: usize) -> Vec<(usize, usize)> {
497 const SAMPLE_WINDOW_BYTES: usize = 64 * 1024;
498
499 let mut ranges = Vec::new();
500 let mut push_range = |start: usize, end: usize| {
501 if start < end && !ranges.contains(&(start, end)) {
502 ranges.push((start, end));
503 }
504 };
505
506 push_range(0, len.min(SAMPLE_WINDOW_BYTES));
507 if len > SAMPLE_WINDOW_BYTES * 2 {
508 let mid_start = len / 2 - SAMPLE_WINDOW_BYTES / 2;
509 let mid_end = (mid_start + SAMPLE_WINDOW_BYTES).min(len);
510 push_range(mid_start, mid_end);
511 }
512 if len > SAMPLE_WINDOW_BYTES {
513 push_range(len - SAMPLE_WINDOW_BYTES, len);
514 }
515
516 ranges
517}
518
519fn extract_bounded_macho_legal_strings(bytes: &[u8]) -> String {
520 if !matches!(
521 FileKind::parse(bytes),
522 Ok(FileKind::MachO32 | FileKind::MachO64 | FileKind::MachOFat32 | FileKind::MachOFat64)
523 ) {
524 return String::new();
525 }
526
527 let mut ranges = Vec::new();
528 for marker in LARGE_MACHO_LEGAL_MARKERS {
529 collect_marker_window_ranges(bytes, marker, &mut ranges);
530 if ranges.len() >= LARGE_MACHO_LEGAL_MAX_WINDOWS {
531 break;
532 }
533 }
534
535 if ranges.is_empty() {
536 return String::new();
537 }
538
539 let mut merged_ranges = merge_overlapping_ranges(ranges);
540 let mut combined_lines = BTreeSet::new();
541 let mut extracted_bytes = 0usize;
542
543 for (start, end) in merged_ranges.drain(..) {
544 if extracted_bytes >= LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES {
545 break;
546 }
547 let remaining = LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES - extracted_bytes;
548 let end = start.saturating_add((end - start).min(remaining));
549 let window_text = extract_printable_strings(&bytes[start..end]);
550 for line in window_text
551 .lines()
552 .map(str::trim)
553 .filter(|line| !line.is_empty())
554 {
555 combined_lines.insert(line.to_string());
556 }
557 extracted_bytes += end - start;
558 }
559
560 combined_lines.into_iter().collect::<Vec<_>>().join("\n")
561}
562
563fn collect_marker_window_ranges(bytes: &[u8], marker: &[u8], ranges: &mut Vec<(usize, usize)>) {
564 if marker.is_empty() || ranges.len() >= LARGE_MACHO_LEGAL_MAX_WINDOWS {
565 return;
566 }
567
568 let mut search_start = 0usize;
569 let mut hits_for_marker = 0usize;
570
571 while search_start + marker.len() <= bytes.len()
572 && ranges.len() < LARGE_MACHO_LEGAL_MAX_WINDOWS
573 && hits_for_marker < LARGE_MACHO_LEGAL_MAX_WINDOWS_PER_MARKER
574 {
575 let Some(relative_match) = bytes[search_start..].iter().position(|&b| b == marker[0])
576 else {
577 break;
578 };
579 let match_start = search_start + relative_match;
580 let match_end = match_start + marker.len();
581 if match_end <= bytes.len() && &bytes[match_start..match_end] == marker {
582 let half_window = LARGE_MACHO_LEGAL_WINDOW_BYTES / 2;
583 let window_start = match_start.saturating_sub(half_window);
584 let window_end = (match_end + half_window).min(bytes.len());
585 ranges.push((window_start, window_end));
586 hits_for_marker += 1;
587 search_start = match_end;
588 } else {
589 search_start = match_start + 1;
590 }
591 }
592}
593
594fn merge_overlapping_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
595 if ranges.is_empty() {
596 return ranges;
597 }
598
599 ranges.sort_unstable_by_key(|&(start, end)| (start, end));
600
601 let mut merged = Vec::with_capacity(ranges.len());
602 let mut current = ranges[0];
603 for (start, end) in ranges.into_iter().skip(1) {
604 if start <= current.1 {
605 current.1 = current.1.max(end);
606 } else {
607 merged.push(current);
608 current = (start, end);
609 }
610 }
611 merged.push(current);
612
613 merged
614}
615
616fn sample_has_promising_printable_strings(bytes: &[u8]) -> bool {
617 let mut structured_signal_seen = false;
618 let promising_license_windows = sampled_printable_window_ranges(bytes.len())
619 .into_iter()
620 .filter(|&(start, end)| {
621 let window = &bytes[start..end];
622 if has_strong_structured_text_signal(window) {
623 structured_signal_seen = true;
624 }
625 has_license_or_notice_signal(window)
626 })
627 .count();
628
629 structured_signal_seen || promising_license_windows >= 2
630}
631
632fn extract_sampled_printable_strings(bytes: &[u8]) -> String {
633 let mut combined_lines = BTreeSet::new();
634
635 for (start, end) in sampled_printable_window_ranges(bytes.len()) {
636 let window_text = extract_printable_strings(&bytes[start..end]);
637 for line in window_text
638 .lines()
639 .map(str::trim)
640 .filter(|line| !line.is_empty())
641 {
642 combined_lines.insert(line.to_string());
643 }
644 }
645
646 combined_lines.into_iter().collect::<Vec<_>>().join("\n")
647}
648
649fn has_license_or_notice_signal(bytes: &[u8]) -> bool {
650 let strings = extract_printable_strings(bytes);
651 if strings.is_empty() {
652 return false;
653 }
654
655 let lower = strings.to_ascii_lowercase();
656 [
657 "copyright",
658 "license",
659 "licensed under",
660 "all rights reserved",
661 "permission is hereby granted",
662 "redistribution and use",
663 "spdx-license-identifier",
664 ]
665 .iter()
666 .any(|marker| lower.contains(marker))
667}
668
669fn has_strong_structured_text_signal(bytes: &[u8]) -> bool {
670 let strings = extract_printable_strings(bytes);
671 if strings.is_empty() {
672 return false;
673 }
674
675 let email_markers = strings.matches('@').count();
676 let url_markers = strings.matches("http://").count() + strings.matches("https://").count();
677
678 email_markers + url_markers >= 3
679}
680
681pub fn extract_printable_strings(bytes: &[u8]) -> String {
682 const MIN_LEN: usize = 4;
683 const MIN_OUTPUT_BYTES: usize = 2_000_000;
684 const MAX_OUTPUT_BYTES_CAP: usize = 16_000_000;
685
686 let max_output_bytes = bytes.len().clamp(MIN_OUTPUT_BYTES, MAX_OUTPUT_BYTES_CAP);
687
688 fn is_printable_ascii(b: u8) -> bool {
689 matches!(b, 0x20..=0x7E)
690 }
691
692 let mut out = String::new();
693 let mut run: Vec<u8> = Vec::new();
694
695 let flush_run = |out: &mut String, run: &mut Vec<u8>| {
696 if run.len() >= MIN_LEN {
697 if !out.is_empty() {
698 out.push('\n');
699 }
700 out.push_str(&String::from_utf8_lossy(run));
701 }
702 run.clear();
703 };
704
705 for &b in bytes {
706 if is_printable_ascii(b) {
707 run.push(b);
708 } else {
709 flush_run(&mut out, &mut run);
710 if out.len() >= max_output_bytes {
711 return out;
712 }
713 }
714 }
715 flush_run(&mut out, &mut run);
716 if out.len() >= max_output_bytes {
717 return out;
718 }
719
720 for start in 0..=1 {
721 run.clear();
722 let mut i = start;
723 while i + 1 < bytes.len() {
724 let b0 = bytes[i];
725 let b1 = bytes[i + 1];
726 let (ch, zero) = if start == 0 { (b0, b1) } else { (b1, b0) };
727 if is_printable_ascii(ch) && zero == 0 {
728 run.push(ch);
729 } else {
730 flush_run(&mut out, &mut run);
731 if out.len() >= max_output_bytes {
732 return out;
733 }
734 }
735 i += 2;
736 }
737 flush_run(&mut out, &mut run);
738 if out.len() >= max_output_bytes {
739 return out;
740 }
741 }
742
743 out
744}