Skip to main content

provenant/utils/file/
extract.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Text extraction for downstream license/copyright detection: chooses a
8//! strategy per input (RTF, PDF, image metadata, font metadata, decoded text,
9//! or bounded binary-string scraping) and augments markdown/HTML license hints.
10
11use 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        // Only surface the near-binary skip when nothing else recovered text and
301        // the file truly drops out of detection.
302        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}