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, 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        // Augment the structured legal fields with the remaining `name` table
226        // records (designer, vendor URL, etc.), decoded record-by-record so
227        // packed UTF-16 storage cannot glue them into run-on URLs the way a raw
228        // whole-binary printable-strings scrape would.
229        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        // Only surface the near-binary skip when nothing else recovered text and
305        // the file truly drops out of detection.
306        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}