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