1use std::borrow::Cow;
5use std::collections::BTreeSet;
6use std::fs;
7use std::io::{BufReader, Cursor, Read};
8use std::panic::{AssertUnwindSafe, catch_unwind};
9use std::path::Path;
10
11use chrono::{TimeZone, Utc};
12use file_format::{FileFormat, Kind as FileFormatKind};
13use flate2::read::ZlibDecoder;
14use glob::Pattern;
15use image::{ImageDecoder, ImageFormat, ImageReader};
16use mime_guess::from_path;
17use quick_xml::events::Event;
18use quick_xml::reader::Reader as XmlReader;
19
20use crate::parsers::windows_executable::extract_windows_executable_metadata_text;
21use crate::utils::font::extract_font_metadata_text;
22use crate::utils::language::detect_language;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ExtractedTextKind {
26 None,
27 Decoded,
28 FontMetadata,
29 Pdf,
30 BinaryStrings,
31 ImageMetadata,
32 WindowsExecutableMetadata,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct FileInfoClassification {
37 pub mime_type: String,
38 pub file_type: String,
39 pub programming_language: Option<String>,
40 pub is_binary: bool,
41 pub is_text: bool,
42 pub is_archive: bool,
43 pub is_media: bool,
44 pub is_source: bool,
45 pub is_script: bool,
46}
47
48const MAX_IMAGE_METADATA_VALUES: usize = 64;
49const MAX_IMAGE_METADATA_TEXT_BYTES: usize = 32 * 1024;
50const BINARY_CONTROL_CHAR_THRESHOLD_DIVISOR: usize = 10;
51const LARGE_OPAQUE_BINARY_SKIP_BYTES: usize = 512 * 1024;
52const JSON_VALIDATION_MAX_BYTES: usize = 4 * 1024 * 1024;
53const PLAIN_TEXT_EXTENSIONS: &[&str] = &[
54 "rst", "rest", "md", "txt", "log", "json", "xml", "yaml", "yml", "toml", "ini",
55];
56const BINARY_EXTENSIONS: &[&str] = &[
57 "pyc", "pyo", "pgm", "pbm", "ppm", "mp3", "mp4", "mpeg", "mpg", "emf",
58];
59const ARCHIVE_EXTENSIONS: &[&str] = &[
60 "zip", "jar", "war", "ear", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar", "apk", "deb", "rpm",
61 "whl", "crate", "egg", "gem", "nupkg", "sqs", "squashfs",
62];
63
64pub fn get_creation_date(metadata: &fs::Metadata) -> Option<String> {
66 metadata.modified().ok().map(|time: std::time::SystemTime| {
67 let seconds_since_epoch = time
68 .duration_since(std::time::UNIX_EPOCH)
69 .unwrap()
70 .as_secs() as i64;
71
72 Utc.timestamp_opt(seconds_since_epoch, 0)
73 .single()
74 .unwrap_or_else(Utc::now)
75 .format("%Y-%m-%d")
76 .to_string()
77 })
78}
79
80pub fn is_path_excluded(path: &Path, exclude_patterns: &[Pattern]) -> bool {
82 let path_str = path.to_string_lossy();
83 let file_name = path
84 .file_name()
85 .map(|name| name.to_string_lossy())
86 .unwrap_or_default();
87
88 for pattern in exclude_patterns {
89 if pattern.matches(&path_str) {
91 return true;
92 }
93
94 if pattern.matches(&file_name) {
96 return true;
97 }
98 }
99
100 false
101}
102
103pub fn decode_bytes_to_string(bytes: &[u8]) -> String {
110 if let Some(decoded) = decode_utf16_text(bytes) {
111 return decoded;
112 }
113
114 match String::from_utf8(bytes.to_vec()) {
115 Ok(s) => s,
116 Err(e) => {
117 let bytes = e.into_bytes();
118 if has_binary_control_chars(&bytes) {
119 return String::new();
120 }
121 bytes.iter().map(|&b| b as char).collect()
122 }
123 }
124}
125
126pub fn extract_text_for_detection(path: &Path, bytes: &[u8]) -> (String, ExtractedTextKind) {
127 let (text, kind, _) = extract_text_for_detection_with_diagnostics(path, bytes);
128 (text, kind)
129}
130
131pub(crate) fn augment_license_detection_text<'a>(path: &Path, text: &'a str) -> Cow<'a, str> {
132 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
133 return Cow::Borrowed(text);
134 };
135 if !matches!(
136 extension.to_ascii_lowercase().as_str(),
137 "md" | "markdown" | "html" | "htm"
138 ) {
139 return Cow::Borrowed(text);
140 }
141
142 let mut hints = Vec::new();
143 if text.contains("CC BY 4.0") || text.contains("creativecommons.org/licenses/by/4.0") {
144 hints.push("Creative Commons Attribution 4.0 International License".to_string());
145 }
146 if text.contains("Apache License (Version 2.0)") || text.contains("Apache License, Version 2.0")
147 {
148 hints.push(
149 "Licensed under the Apache License, Version 2.0. http://www.apache.org/licenses/LICENSE-2.0"
150 .to_string(),
151 );
152 }
153
154 hints.extend(extract_shields_license_badge_hints(text));
155
156 if hints.is_empty() {
157 Cow::Borrowed(text)
158 } else {
159 let mut augmented =
160 String::with_capacity(text.len() + hints.iter().map(String::len).sum::<usize>() + 8);
161 augmented.push_str(text);
162 augmented.push_str("\n\n");
163 for (index, hint) in hints.into_iter().enumerate() {
164 if index > 0 {
165 augmented.push('\n');
166 }
167 augmented.push_str(&hint);
168 }
169 Cow::Owned(augmented)
170 }
171}
172
173fn extract_shields_license_badge_hints(text: &str) -> Vec<String> {
174 let mut hints = Vec::new();
175 let mut rest = text;
176 let needle = "img.shields.io/badge/license-";
177
178 while let Some(index) = rest.find(needle) {
179 let start = index + needle.len();
180 let suffix = &rest[start..];
181 let end = suffix
182 .find([')', ']', '"', '\'', ' ', '\n'])
183 .unwrap_or(suffix.len());
184 let badge = &suffix[..end];
185 let Some(badge) = badge.strip_suffix(".svg") else {
186 rest = &suffix[end..];
187 continue;
188 };
189
190 let mut segments: Vec<_> = badge
191 .split('-')
192 .filter(|segment| !segment.is_empty())
193 .collect();
194 if segments.len() < 2 {
195 rest = &suffix[end..];
196 continue;
197 }
198 segments.pop();
199 let candidate = segments.join("-").replace("%20", " ").replace('_', "-");
200 if !candidate.is_empty() {
201 hints.push(canonical_shields_license_hint(&candidate));
202 }
203
204 rest = &suffix[end..];
205 }
206
207 hints.sort();
208 hints.dedup();
209 hints
210}
211
212fn canonical_shields_license_hint(candidate: &str) -> String {
213 match candidate.trim() {
214 "MIT" => "The MIT License".to_string(),
215 "Apache-2.0" | "Apache 2.0" => "Apache License 2.0".to_string(),
216 other => format!("{other} License"),
217 }
218}
219
220pub(crate) fn extract_text_for_detection_with_diagnostics(
221 path: &Path,
222 bytes: &[u8],
223) -> (String, ExtractedTextKind, Option<String>) {
224 let ext = path
225 .extension()
226 .and_then(|e| e.to_str())
227 .map(|s| s.to_ascii_lowercase());
228 let detected_format = detect_file_format(bytes);
229
230 if looks_like_rtf(bytes, ext.as_deref()) {
231 let text = extract_rtf_text(bytes);
232 return if text.trim().is_empty() {
233 (String::new(), ExtractedTextKind::None, None)
234 } else {
235 (text, ExtractedTextKind::Decoded, None)
236 };
237 }
238
239 if looks_like_pdf(bytes) || detected_format.short_name() == Some("PDF") {
240 let (text, scan_error) = extract_pdf_text(path, bytes);
241 return if text.is_empty() {
242 (String::new(), ExtractedTextKind::None, scan_error)
243 } else {
244 (text, ExtractedTextKind::Pdf, None)
245 };
246 }
247
248 if let Some(format) = supported_image_metadata_format(ext.as_deref(), detected_format) {
249 let text = extract_image_metadata_text(bytes, format);
250 return if text.is_empty() {
251 if is_supported_image_container(bytes, format) {
252 (String::new(), ExtractedTextKind::None, None)
253 } else {
254 let decoded = decode_bytes_to_string(bytes);
255 if decoded.is_empty() {
256 (String::new(), ExtractedTextKind::None, None)
257 } else {
258 (decoded, ExtractedTextKind::Decoded, None)
259 }
260 }
261 } else {
262 (text, ExtractedTextKind::ImageMetadata, None)
263 };
264 }
265
266 if let Some(text) = extract_font_metadata_text(path, bytes) {
267 let strings = extract_printable_strings(bytes);
268 let combined = if strings.is_empty() {
269 text
270 } else {
271 combine_extracted_text_fragments(Some(text), strings)
272 };
273 return (combined, ExtractedTextKind::FontMetadata, None);
274 }
275
276 let windows_executable_metadata_text = extract_windows_executable_metadata_text(bytes);
277 let large_opaque_binary = windows_executable_metadata_text.is_none()
278 && is_large_opaque_binary_candidate(bytes, detected_format);
279
280 if should_skip_large_opaque_binary_text_extraction(path, bytes, detected_format) {
281 return windows_metadata_or_empty_result(windows_executable_metadata_text);
282 }
283
284 if should_skip_binary_string_extraction(path, bytes, detected_format) {
285 return (String::new(), ExtractedTextKind::None, None);
286 }
287
288 if !large_opaque_binary {
289 let decoded = decode_bytes_to_string(bytes);
290 if !decoded.is_empty() {
291 let combined =
292 combine_extracted_text_fragments(windows_executable_metadata_text, decoded);
293 return (combined, ExtractedTextKind::Decoded, None);
294 }
295 }
296
297 let text = if large_opaque_binary {
298 extract_sampled_printable_strings(bytes)
299 } else {
300 extract_printable_strings(bytes)
301 };
302 if text.is_empty() {
303 windows_metadata_or_empty_result(windows_executable_metadata_text)
304 } else {
305 (
306 combine_extracted_text_fragments(windows_executable_metadata_text, text),
307 ExtractedTextKind::BinaryStrings,
308 None,
309 )
310 }
311}
312
313fn combine_extracted_text_fragments(prefix: Option<String>, suffix: String) -> String {
314 match prefix {
315 Some(prefix) if !prefix.is_empty() && !suffix.is_empty() => format!("{prefix}\n{suffix}"),
316 Some(prefix) if !prefix.is_empty() => prefix,
317 _ => suffix,
318 }
319}
320
321fn windows_metadata_or_empty_result(
322 windows_executable_metadata_text: Option<String>,
323) -> (String, ExtractedTextKind, Option<String>) {
324 if let Some(metadata_text) = windows_executable_metadata_text {
325 (
326 metadata_text,
327 ExtractedTextKind::WindowsExecutableMetadata,
328 None,
329 )
330 } else {
331 (String::new(), ExtractedTextKind::None, None)
332 }
333}
334
335pub fn classify_file_info(path: &Path, bytes: &[u8]) -> FileInfoClassification {
336 let detected_format = detect_file_format(bytes);
337 let detected_language = detect_language(path, bytes);
338 let is_binary = detect_is_binary(path, bytes, detected_format, detected_language.as_deref());
339 let is_text = !is_binary;
340 let mime_type = detect_mime_type(path, bytes, detected_format, detected_language.as_deref());
341 let is_archive = detect_is_archive(path, bytes, &mime_type, is_text, detected_format);
342 let is_media = detect_is_media(path, bytes, &mime_type, detected_format);
343 let is_script = detect_is_script(path, bytes, detected_language.as_deref(), is_text);
344 let is_source = detect_is_source(path, detected_language.as_deref(), is_text, is_script);
345 let programming_language = is_source.then(|| detected_language.clone()).flatten();
346 let file_type = detect_file_type(
347 path,
348 bytes,
349 detected_format,
350 &mime_type,
351 programming_language.as_deref(),
352 is_binary,
353 is_text,
354 is_archive,
355 is_media,
356 is_script,
357 );
358
359 FileInfoClassification {
360 mime_type,
361 file_type,
362 programming_language,
363 is_binary,
364 is_text,
365 is_archive,
366 is_media,
367 is_source,
368 is_script,
369 }
370}
371
372fn detect_file_format(bytes: &[u8]) -> FileFormat {
373 FileFormat::from_reader(Cursor::new(bytes)).unwrap_or(FileFormat::ArbitraryBinaryData)
374}
375
376const CORRUPTED_UTF16_BOM_PREFIX: &[u8] = &[0xEF, 0xBF, 0xBD, 0xEF, 0xBF, 0xBD];
377
378fn is_utf8_text(bytes: &[u8]) -> bool {
379 std::str::from_utf8(bytes).is_ok()
380}
381
382fn strip_corrupted_utf16_bom_prefix(bytes: &[u8]) -> &[u8] {
383 bytes
384 .strip_prefix(CORRUPTED_UTF16_BOM_PREFIX)
385 .unwrap_or(bytes)
386}
387
388fn decode_utf16_units(bytes: &[u8], is_le: bool, require_text_shape: bool) -> Option<String> {
389 if bytes.is_empty() || !bytes.len().is_multiple_of(2) {
390 return None;
391 }
392
393 let code_units: Vec<u16> = bytes
394 .chunks_exact(2)
395 .map(|chunk| {
396 if is_le {
397 u16::from_le_bytes([chunk[0], chunk[1]])
398 } else {
399 u16::from_be_bytes([chunk[0], chunk[1]])
400 }
401 })
402 .collect();
403
404 let decoded = std::char::decode_utf16(code_units)
405 .collect::<Result<String, _>>()
406 .ok()?;
407
408 if !require_text_shape {
409 return (!decoded.contains('\0')).then_some(decoded);
410 }
411
412 let visible = decoded
413 .chars()
414 .filter(|ch| !ch.is_control() || matches!(ch, '\n' | '\r' | '\t'))
415 .count();
416 if visible < 3 || decoded.contains('\0') {
417 return None;
418 }
419
420 let alpha = decoded.chars().filter(|ch| ch.is_alphabetic()).count();
421 let punctuation = decoded
422 .chars()
423 .filter(|ch| {
424 matches!(
425 ch,
426 '{' | '}'
427 | '['
428 | ']'
429 | '<'
430 | '>'
431 | '('
432 | ')'
433 | ':'
434 | ';'
435 | ','
436 | '"'
437 | '\''
438 | '/'
439 | '='
440 | '-'
441 | '_'
442 | '#'
443 | '!'
444 )
445 })
446 .count();
447 let whitespace = decoded.chars().filter(|ch| ch.is_whitespace()).count();
448
449 let textish = alpha + punctuation + whitespace;
450 if textish + (visible / 5) < visible || (alpha == 0 && punctuation < 2) {
451 return None;
452 }
453
454 Some(decoded)
455}
456
457fn detect_utf16_endianness(bytes: &[u8]) -> Option<bool> {
458 let stripped = strip_corrupted_utf16_bom_prefix(bytes);
459 if stripped.len() < 4 || !stripped.len().is_multiple_of(2) {
460 return None;
461 }
462
463 let pair_count = stripped.len() / 2;
464 let even_zero = stripped.iter().step_by(2).filter(|&&b| b == 0).count();
465 let odd_zero = stripped
466 .iter()
467 .skip(1)
468 .step_by(2)
469 .filter(|&&b| b == 0)
470 .count();
471
472 let looks_like_be = even_zero * 3 >= pair_count && odd_zero * 6 <= pair_count;
473 let looks_like_le = odd_zero * 3 >= pair_count && even_zero * 6 <= pair_count;
474
475 match (looks_like_le, looks_like_be) {
476 (true, false) => Some(true),
477 (false, true) => Some(false),
478 (true, true) => Some(true),
479 (false, false) => None,
480 }
481}
482
483fn decode_utf16_text(bytes: &[u8]) -> Option<String> {
484 if let Some(decoded) = decode_utf16_bom_text(bytes) {
485 return Some(decoded);
486 }
487
488 let stripped = strip_corrupted_utf16_bom_prefix(bytes);
489 match detect_utf16_endianness(bytes) {
490 Some(true) => decode_utf16_units(stripped, true, true),
491 Some(false) => decode_utf16_units(stripped, false, true),
492 None => None,
493 }
494}
495
496fn decode_utf16_json_text(bytes: &[u8]) -> Option<String> {
497 if bytes.len() >= 2 {
498 let (is_le, body) = match bytes {
499 [0xFF, 0xFE, rest @ ..] => (true, rest),
500 [0xFE, 0xFF, rest @ ..] => (false, rest),
501 _ => {
502 let stripped = strip_corrupted_utf16_bom_prefix(bytes);
503 return match detect_utf16_endianness(bytes) {
504 Some(true) => decode_utf16_units(stripped, true, false),
505 Some(false) => decode_utf16_units(stripped, false, false),
506 None => None,
507 };
508 }
509 };
510
511 if body.is_empty() || !body.len().is_multiple_of(2) {
512 return None;
513 }
514
515 return decode_utf16_units(body, is_le, false);
516 }
517
518 None
519}
520
521fn decode_utf16_bom_text(bytes: &[u8]) -> Option<String> {
522 if bytes.len() < 2 || !bytes.len().is_multiple_of(2) {
523 return None;
524 }
525
526 let (is_le, body) = match bytes {
527 [0xFF, 0xFE, rest @ ..] => (true, rest),
528 [0xFE, 0xFF, rest @ ..] => (false, rest),
529 _ => return None,
530 };
531
532 if body.is_empty() || body.len() % 2 != 0 {
533 return None;
534 }
535
536 decode_utf16_units(body, is_le, true)
537}
538
539fn has_binary_control_chars(bytes: &[u8]) -> bool {
540 let control_count = bytes
541 .iter()
542 .filter(|&&b| b < 0x09 || (b > 0x0D && b < 0x20))
543 .count();
544 control_count > bytes.len() / BINARY_CONTROL_CHAR_THRESHOLD_DIVISOR
545}
546
547fn has_decodable_text(bytes: &[u8]) -> bool {
548 bytes.is_empty()
549 || is_utf8_text(bytes)
550 || decode_utf16_text(bytes).is_some()
551 || !has_binary_control_chars(bytes)
552}
553
554fn looks_like_textual_bytes(bytes: &[u8]) -> bool {
555 if bytes.is_empty() || is_utf8_text(bytes) {
556 return true;
557 }
558 if let Some(decoded) = decode_utf16_text(bytes) {
559 return decoded
560 .chars()
561 .any(|ch| !ch.is_control() || matches!(ch, '\n' | '\r' | '\t'));
562 }
563
564 let printable_count = bytes
565 .iter()
566 .filter(|&&b| matches!(b, b'\n' | b'\r' | b'\t') || (0x20..=0x7e).contains(&b))
567 .count();
568 printable_count * 2 >= bytes.len()
569}
570
571fn is_textual_media_type(media_type: &str) -> bool {
572 media_type.starts_with("text/")
573 || matches!(
574 media_type,
575 "application/json" | "application/xml" | "text/xml"
576 )
577 || media_type.ends_with("+json")
578 || media_type.ends_with("+xml")
579}
580
581fn is_textual_format(detected_format: FileFormat) -> bool {
582 matches!(detected_format, FileFormat::Empty | FileFormat::PlainText)
583 || is_textual_media_type(detected_format.media_type())
584}
585
586fn is_known_binary_format(detected_format: FileFormat) -> bool {
587 !matches!(detected_format, FileFormat::ArbitraryBinaryData)
588 && !is_textual_format(detected_format)
589}
590
591pub fn detect_mime_type(
592 path: &Path,
593 bytes: &[u8],
594 detected_format: FileFormat,
595 programming_language: Option<&str>,
596) -> String {
597 if bytes.is_empty() {
598 return "inode/x-empty".to_string();
599 }
600
601 if lower_extension(path).as_deref() == Some("json") {
602 if let Some(is_binary) = json_binary_override(bytes) {
603 if is_binary {
604 return "application/octet-stream".to_string();
605 }
606 if has_valid_json_text(bytes) {
607 return "application/json".to_string();
608 }
609 return "text/plain".to_string();
610 }
611 if has_valid_json_text(bytes) {
612 return "application/json".to_string();
613 }
614 if has_decodable_text(bytes) && looks_like_textual_bytes(bytes) {
615 return "text/plain".to_string();
616 }
617 return "application/octet-stream".to_string();
618 }
619
620 if is_zip_archive(bytes) {
621 return detect_zip_like_mime(path);
622 }
623
624 if looks_like_deb(bytes, path) {
625 return "application/vnd.debian.binary-package".to_string();
626 }
627
628 if looks_like_rpm(bytes, path) {
629 return "application/x-rpm".to_string();
630 }
631
632 let guessed_mime = from_path(path)
633 .first_or_octet_stream()
634 .essence_str()
635 .to_string();
636
637 let mime_type = match detected_format {
638 FileFormat::Empty => "inode/x-empty".to_string(),
639 FileFormat::PlainText => {
640 if guessed_mime == "application/octet-stream" || guessed_mime.starts_with("video/") {
641 "text/plain".to_string()
642 } else {
643 guessed_mime.clone()
644 }
645 }
646 _ => {
647 let detected_mime = detected_format.media_type();
648 if detected_mime == "application/octet-stream"
649 && guessed_mime != "application/octet-stream"
650 {
651 guessed_mime.clone()
652 } else {
653 detected_mime.to_string()
654 }
655 }
656 };
657
658 normalize_mime_type(path, bytes, programming_language, &mime_type)
659}
660
661fn normalize_mime_type(
662 path: &Path,
663 bytes: &[u8],
664 programming_language: Option<&str>,
665 mime_type: &str,
666) -> String {
667 if should_prefer_text_mime(path, bytes, programming_language, mime_type) {
668 return "text/plain".to_string();
669 }
670
671 mime_type.to_string()
672}
673
674fn should_prefer_text_mime(
675 path: &Path,
676 bytes: &[u8],
677 programming_language: Option<&str>,
678 mime_type: &str,
679) -> bool {
680 has_decodable_text(bytes)
681 && looks_like_textual_bytes(bytes)
682 && is_textual_source_candidate(path, programming_language)
683 && (mime_type.starts_with("video/") || mime_type == "application/octet-stream")
684}
685
686fn has_valid_json_text(bytes: &[u8]) -> bool {
687 if bytes.len() > JSON_VALIDATION_MAX_BYTES {
688 return false;
689 }
690
691 serde_json::from_slice::<serde_json::Value>(bytes).is_ok()
692 || decode_utf16_json_text(bytes)
693 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
694 .is_some()
695}
696
697fn is_wrapped_invalid_json_string_text(bytes: &[u8]) -> bool {
698 !bytes.contains(&0)
699 && !bytes.contains(&0xFF)
700 && bytes.starts_with(b"[\"")
701 && bytes.ends_with(b"\"]")
702 && bytes.len() >= 8
703}
704
705fn json_binary_override(bytes: &[u8]) -> Option<bool> {
706 if has_valid_json_text(bytes) {
707 return Some(false);
708 }
709
710 if bytes.contains(&0) {
711 return Some(true);
712 }
713
714 if bytes.contains(&0xFF) && (bytes.len() <= 5 || bytes.len() > 1024) {
715 return Some(true);
716 }
717
718 if is_wrapped_invalid_json_string_text(bytes) {
719 return Some(false);
720 }
721
722 None
723}
724
725fn detect_is_binary(
726 path: &Path,
727 bytes: &[u8],
728 detected_format: FileFormat,
729 programming_language: Option<&str>,
730) -> bool {
731 if lower_extension(path).as_deref() == Some("json")
732 && let Some(is_binary) = json_binary_override(bytes)
733 {
734 return is_binary;
735 }
736
737 if is_textual_format(detected_format) {
738 return false;
739 }
740
741 if lower_extension(path)
742 .as_deref()
743 .is_some_and(|ext| BINARY_EXTENSIONS.contains(&ext))
744 {
745 return true;
746 }
747
748 if should_treat_binary_bytes_as_text(path, bytes, programming_language) {
749 return false;
750 }
751
752 has_binary_control_chars(bytes)
753 || is_known_binary_format(detected_format)
754 || (matches!(detected_format, FileFormat::ArbitraryBinaryData)
755 && !looks_like_textual_bytes(bytes))
756}
757
758fn should_treat_binary_bytes_as_text(
759 path: &Path,
760 bytes: &[u8],
761 programming_language: Option<&str>,
762) -> bool {
763 has_decodable_text(bytes)
764 && looks_like_textual_bytes(bytes)
765 && (bytes.starts_with(b"#!") || is_textual_source_candidate(path, programming_language))
766}
767
768fn detect_is_archive(
769 path: &Path,
770 bytes: &[u8],
771 mime_type: &str,
772 is_text: bool,
773 detected_format: FileFormat,
774) -> bool {
775 if is_text {
776 return false;
777 }
778
779 lower_extension(path)
780 .as_deref()
781 .is_some_and(|ext| ARCHIVE_EXTENSIONS.contains(&ext))
782 || matches!(
783 detected_format.kind(),
784 FileFormatKind::Archive | FileFormatKind::Compressed | FileFormatKind::Package
785 )
786 || is_zip_archive(bytes)
787 || looks_like_gzip(bytes)
788 || looks_like_bzip2(bytes)
789 || looks_like_xz(bytes)
790 || looks_like_deb(bytes, path)
791 || looks_like_rpm(bytes, path)
792 || looks_like_squashfs(bytes, path)
793 || mime_type.contains("zip")
794 || mime_type.contains("compressed")
795 || mime_type.contains("tar")
796 || mime_type.contains("x-rpm")
797 || mime_type.contains("debian")
798}
799
800fn detect_is_media(
801 path: &Path,
802 bytes: &[u8],
803 mime_type: &str,
804 detected_format: FileFormat,
805) -> bool {
806 media_mime_from_content(bytes).is_some()
807 || matches!(
808 detected_format.kind(),
809 FileFormatKind::Audio | FileFormatKind::Image | FileFormatKind::Video
810 )
811 || mime_type.starts_with("image/")
812 || mime_type.starts_with("audio/")
813 || mime_type.starts_with("video/")
814 || (mime_type == "application/octet-stream"
815 && lower_extension(path).as_deref() == Some("tga")
816 && !has_binary_control_chars(bytes))
817}
818
819fn detect_is_script(
820 path: &Path,
821 bytes: &[u8],
822 programming_language: Option<&str>,
823 is_text: bool,
824) -> bool {
825 if !is_text || is_makefile(path) {
826 return false;
827 }
828
829 bytes.starts_with(b"#!")
830 || lower_extension(path).as_deref().is_some_and(|ext| {
831 matches!(
832 ext,
833 "sh" | "bash" | "zsh" | "fish" | "ksh" | "ps1" | "psm1" | "psd1" | "awk"
834 )
835 })
836 || matches!(
837 programming_language,
838 Some(
839 "Shell"
840 | "Bash"
841 | "Zsh"
842 | "Fish"
843 | "Ksh"
844 | "Python"
845 | "Ruby"
846 | "Perl"
847 | "PHP"
848 | "PowerShell"
849 | "Awk"
850 )
851 )
852}
853
854fn detect_is_source(
855 path: &Path,
856 programming_language: Option<&str>,
857 is_text: bool,
858 is_script: bool,
859) -> bool {
860 if !is_text || is_plain_text(path) || is_makefile(path) || is_source_map(path) {
861 return false;
862 }
863
864 if is_c_like_source(path) || is_java_like_source(path) {
865 return true;
866 }
867
868 programming_language.is_some() || is_script
869}
870
871#[allow(clippy::too_many_arguments)]
872fn detect_file_type(
873 path: &Path,
874 bytes: &[u8],
875 detected_format: FileFormat,
876 mime_type: &str,
877 programming_language: Option<&str>,
878 is_binary: bool,
879 is_text: bool,
880 is_archive: bool,
881 is_media: bool,
882 is_script: bool,
883) -> String {
884 if bytes.is_empty() {
885 return "empty".to_string();
886 }
887
888 if looks_like_pdf(bytes) {
889 return "PDF document".to_string();
890 }
891
892 if let Some(file_type) = media_file_type_from_content(bytes) {
893 return file_type.to_string();
894 }
895
896 if is_archive {
897 return archive_file_type(path, bytes, detected_format);
898 }
899
900 if is_script {
901 return script_file_type(programming_language, bytes);
902 }
903
904 if is_text {
905 if lower_extension(path).as_deref() == Some("json") {
906 if has_valid_json_text(bytes) {
907 return "JSON text data".to_string();
908 }
909 return text_file_type(bytes);
910 }
911 if lower_extension(path).as_deref() == Some("xml") {
912 return "XML text data".to_string();
913 }
914 if matches!(lower_extension(path).as_deref(), Some("yaml" | "yml")) {
915 return "YAML text data".to_string();
916 }
917 if lower_extension(path).as_deref() == Some("toml") {
918 return "TOML text data".to_string();
919 }
920 if matches!(
921 lower_extension(path).as_deref(),
922 Some("ini" | "cfg" | "conf")
923 ) {
924 return "INI text data".to_string();
925 }
926 if matches!(lower_file_name(path).as_str(), ".gitmodules" | ".gitconfig") {
927 return "Git configuration text".to_string();
928 }
929 if matches!(lower_extension(path).as_deref(), Some("md" | "markdown")) {
930 return text_file_type(bytes);
931 }
932 if programming_language.is_some() && !is_media {
933 return source_file_type(programming_language, bytes);
934 }
935 return text_file_type(bytes);
936 }
937
938 if let Some(file_type) = format_based_file_type(detected_format) {
939 return file_type;
940 }
941
942 if is_binary && mime_type == "application/octet-stream" {
943 return "data".to_string();
944 }
945
946 mime_type.to_string()
947}
948
949fn is_textual_source_candidate(path: &Path, programming_language: Option<&str>) -> bool {
950 if matches!(programming_language, Some(language) if is_source_like_language(language)) {
951 return true;
952 }
953
954 if matches!(
955 lower_file_name(path).as_str(),
956 "dockerfile"
957 | "containerfile"
958 | "containerfile.core"
959 | "apkbuild"
960 | "podfile"
961 | "jamfile"
962 | "jamroot"
963 | "meson.build"
964 | "build"
965 | "workspace"
966 | "buck"
967 | "default.nix"
968 | "flake.nix"
969 | "shell.nix"
970 ) {
971 return true;
972 }
973
974 path.extension()
975 .and_then(|ext| ext.to_str())
976 .is_some_and(|ext| {
977 matches!(
978 ext.to_ascii_lowercase().as_str(),
979 "rs" | "py"
980 | "js"
981 | "mjs"
982 | "cjs"
983 | "jsx"
984 | "ts"
985 | "mts"
986 | "cts"
987 | "tsx"
988 | "c"
989 | "cpp"
990 | "cc"
991 | "cxx"
992 | "h"
993 | "hpp"
994 | "m"
995 | "mm"
996 | "s"
997 | "asm"
998 | "java"
999 | "go"
1000 | "rb"
1001 | "php"
1002 | "pl"
1003 | "swift"
1004 | "sh"
1005 | "bash"
1006 | "zsh"
1007 | "fish"
1008 | "ksh"
1009 | "ps1"
1010 | "psm1"
1011 | "psd1"
1012 | "awk"
1013 | "kt"
1014 | "kts"
1015 | "dart"
1016 | "scala"
1017 | "groovy"
1018 | "gradle"
1019 | "gvy"
1020 | "gy"
1021 | "gsh"
1022 | "cs"
1023 | "fs"
1024 | "fsx"
1025 | "r"
1026 | "lua"
1027 | "jl"
1028 | "ex"
1029 | "exs"
1030 | "clj"
1031 | "cljs"
1032 | "cljc"
1033 | "hs"
1034 | "erl"
1035 | "nix"
1036 | "zig"
1037 | "bzl"
1038 | "bazel"
1039 | "star"
1040 | "sky"
1041 | "ml"
1042 | "mli"
1043 | "tex"
1044 )
1045 })
1046}
1047
1048fn is_source_like_language(language: &str) -> bool {
1049 matches!(
1050 language,
1051 "Rust"
1052 | "Python"
1053 | "JavaScript"
1054 | "TypeScript"
1055 | "JavaScript/TypeScript"
1056 | "C"
1057 | "C++"
1058 | "Objective-C"
1059 | "Objective-C++"
1060 | "GAS"
1061 | "Java"
1062 | "Go"
1063 | "Ruby"
1064 | "PHP"
1065 | "Perl"
1066 | "Swift"
1067 | "Shell"
1068 | "PowerShell"
1069 | "Awk"
1070 | "Kotlin"
1071 | "Dart"
1072 | "Scala"
1073 | "C#"
1074 | "F#"
1075 | "R"
1076 | "Lua"
1077 | "Julia"
1078 | "Elixir"
1079 | "Clojure"
1080 | "Haskell"
1081 | "Erlang"
1082 | "Groovy"
1083 | "Nix"
1084 | "Zig"
1085 | "Starlark"
1086 | "OCaml"
1087 | "Meson"
1088 | "TeX"
1089 | "Dockerfile"
1090 | "Makefile"
1091 | "Jamfile"
1092 )
1093}
1094
1095fn extension(path: &Path) -> Option<&str> {
1096 path.extension().and_then(|ext| ext.to_str())
1097}
1098
1099fn lower_extension(path: &Path) -> Option<String> {
1100 extension(path).map(|ext| ext.to_ascii_lowercase())
1101}
1102
1103fn lower_file_name(path: &Path) -> String {
1104 path.file_name()
1105 .and_then(|name| name.to_str())
1106 .map(|name| name.to_ascii_lowercase())
1107 .unwrap_or_default()
1108}
1109
1110fn is_plain_text(path: &Path) -> bool {
1111 lower_extension(path)
1112 .as_deref()
1113 .is_some_and(|ext| PLAIN_TEXT_EXTENSIONS.contains(&ext))
1114}
1115
1116fn is_makefile(path: &Path) -> bool {
1117 matches!(lower_file_name(path).as_str(), "makefile" | "makefile.inc")
1118}
1119
1120fn is_source_map(path: &Path) -> bool {
1121 let path_lower = path.to_string_lossy().to_ascii_lowercase();
1122 path_lower.ends_with(".js.map") || path_lower.ends_with(".css.map")
1123}
1124
1125fn is_c_like_source(path: &Path) -> bool {
1126 lower_extension(path).as_deref().is_some_and(|ext| {
1127 matches!(
1128 ext,
1129 "c" | "cc"
1130 | "cp"
1131 | "cpp"
1132 | "cxx"
1133 | "c++"
1134 | "h"
1135 | "hh"
1136 | "hpp"
1137 | "hxx"
1138 | "h++"
1139 | "i"
1140 | "ii"
1141 | "m"
1142 | "s"
1143 | "asm"
1144 )
1145 })
1146}
1147
1148fn is_java_like_source(path: &Path) -> bool {
1149 lower_extension(path)
1150 .as_deref()
1151 .is_some_and(|ext| matches!(ext, "java" | "aj" | "jad" | "ajt"))
1152}
1153
1154fn format_based_file_type(detected_format: FileFormat) -> Option<String> {
1155 match detected_format {
1156 FileFormat::ArbitraryBinaryData | FileFormat::Empty | FileFormat::PlainText => None,
1157 format if format.short_name() == Some("PDF") => Some("PDF document".to_string()),
1158 format => Some(match format.kind() {
1159 FileFormatKind::Image => short_name_or_name(&format, "image data"),
1160 FileFormatKind::Audio => short_name_or_name(&format, "audio data"),
1161 FileFormatKind::Video => short_name_or_name(&format, "video data"),
1162 _ => format.name().to_string(),
1163 }),
1164 }
1165}
1166
1167fn short_name_or_name(format: &FileFormat, suffix: &str) -> String {
1168 format
1169 .short_name()
1170 .map(|short_name| format!("{short_name} {suffix}"))
1171 .unwrap_or_else(|| format!("{} {suffix}", format.name()))
1172}
1173
1174fn detect_zip_like_mime(path: &Path) -> String {
1175 match extension(path).map(|ext| ext.to_ascii_lowercase()) {
1176 Some(ext) if ext == "apk" => "application/vnd.android.package-archive".to_string(),
1177 Some(ext) if matches!(ext.as_str(), "jar" | "war" | "ear") => {
1178 "application/java-archive".to_string()
1179 }
1180 _ => "application/zip".to_string(),
1181 }
1182}
1183
1184fn media_mime_from_content(bytes: &[u8]) -> Option<&'static str> {
1185 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
1186 Some("image/png")
1187 } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
1188 Some("image/jpeg")
1189 } else if bytes.starts_with(b"II\x2a\x00") || bytes.starts_with(b"MM\x00\x2a") {
1190 Some("image/tiff")
1191 } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
1192 Some("image/webp")
1193 } else {
1194 None
1195 }
1196}
1197
1198fn media_file_type_from_content(bytes: &[u8]) -> Option<&'static str> {
1199 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
1200 Some("PNG image data")
1201 } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
1202 Some("JPEG image data")
1203 } else if bytes.starts_with(b"II\x2a\x00") || bytes.starts_with(b"MM\x00\x2a") {
1204 Some("TIFF image data")
1205 } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
1206 Some("WebP image data")
1207 } else {
1208 None
1209 }
1210}
1211
1212fn looks_like_pdf(bytes: &[u8]) -> bool {
1213 bytes.starts_with(b"%PDF-")
1214}
1215
1216fn looks_like_rtf(bytes: &[u8], ext: Option<&str>) -> bool {
1217 ext == Some("rtf") || bytes.starts_with(b"{\\rtf")
1218}
1219
1220fn extract_rtf_text(bytes: &[u8]) -> String {
1221 let text = String::from_utf8_lossy(bytes);
1222 let chars: Vec<char> = text.chars().collect();
1223 let mut output = String::new();
1224 let mut index = 0usize;
1225
1226 while index < chars.len() {
1227 match chars[index] {
1228 '{' | '}' => {
1229 index += 1;
1230 }
1231 '\\' => {
1232 index += 1;
1233 if index >= chars.len() {
1234 break;
1235 }
1236
1237 match chars[index] {
1238 '\\' | '{' | '}' => {
1239 output.push(chars[index]);
1240 index += 1;
1241 }
1242 '\'' => {
1243 if index + 2 < chars.len() {
1244 let hex = [chars[index + 1], chars[index + 2]];
1245 let hex: String = hex.iter().collect();
1246 if let Ok(value) = u8::from_str_radix(&hex, 16) {
1247 output.push(value as char);
1248 index += 3;
1249 continue;
1250 }
1251 }
1252 index += 1;
1253 }
1254 control if control.is_ascii_alphabetic() => {
1255 let start = index;
1256 while index < chars.len() && chars[index].is_ascii_alphabetic() {
1257 index += 1;
1258 }
1259 let control_word: String = chars[start..index].iter().collect();
1260
1261 let number_start = index;
1262 if index < chars.len()
1263 && (chars[index] == '-' || chars[index].is_ascii_digit())
1264 {
1265 index += 1;
1266 while index < chars.len() && chars[index].is_ascii_digit() {
1267 index += 1;
1268 }
1269 }
1270 let parameter: String = chars[number_start..index].iter().collect();
1271
1272 if index < chars.len() && chars[index] == ' ' {
1273 index += 1;
1274 }
1275
1276 match control_word.as_str() {
1277 "par" | "line" => output.push('\n'),
1278 "tab" => output.push('\t'),
1279 "emdash" => output.push('—'),
1280 "endash" => output.push('–'),
1281 "bullet" => output.push('•'),
1282 "lquote" | "rquote" => output.push('\''),
1283 "ldblquote" | "rdblquote" => output.push('"'),
1284 "u" => {
1285 if let Ok(codepoint) = parameter.parse::<i32>() {
1286 let normalized = if codepoint < 0 {
1287 codepoint + 65_536
1288 } else {
1289 codepoint
1290 };
1291 if let Ok(normalized) = u32::try_from(normalized)
1292 && let Some(ch) = char::from_u32(normalized)
1293 {
1294 output.push(ch);
1295 }
1296 }
1297
1298 if index < chars.len()
1299 && !matches!(chars[index], '\\' | '{' | '}' | '\n' | '\r')
1300 {
1301 index += 1;
1302 }
1303 }
1304 _ => {}
1305 }
1306 }
1307 _ => {
1308 index += 1;
1309 }
1310 }
1311 }
1312 ch => {
1313 output.push(ch);
1314 index += 1;
1315 }
1316 }
1317 }
1318
1319 output
1320 .replace(['\r', '\u{0c}'], "\n")
1321 .lines()
1322 .map(str::trim_end)
1323 .collect::<Vec<_>>()
1324 .join("\n")
1325}
1326
1327fn looks_like_gzip(bytes: &[u8]) -> bool {
1328 bytes.starts_with(&[0x1f, 0x8b])
1329}
1330
1331fn looks_like_bzip2(bytes: &[u8]) -> bool {
1332 bytes.starts_with(b"BZh")
1333}
1334
1335fn looks_like_xz(bytes: &[u8]) -> bool {
1336 bytes.starts_with(&[0xfd, b'7', b'z', b'X', b'Z', 0x00])
1337}
1338
1339fn looks_like_deb(bytes: &[u8], path: &Path) -> bool {
1340 lower_extension(path).as_deref() == Some("deb") && bytes.starts_with(b"!<arch>\n")
1341}
1342
1343fn looks_like_rpm(bytes: &[u8], path: &Path) -> bool {
1344 lower_extension(path).as_deref() == Some("rpm") && bytes.starts_with(&[0xed, 0xab, 0xee, 0xdb])
1345}
1346
1347fn looks_like_squashfs(bytes: &[u8], path: &Path) -> bool {
1348 lower_extension(path)
1349 .as_deref()
1350 .is_some_and(|ext| matches!(ext, "sqs" | "squashfs"))
1351 && (bytes.starts_with(&[0x68, 0x73, 0x71, 0x73])
1352 || bytes.starts_with(&[0x73, 0x71, 0x73, 0x68]))
1353}
1354
1355fn archive_file_type(path: &Path, bytes: &[u8], detected_format: FileFormat) -> String {
1356 if looks_like_deb(bytes, path) {
1357 "debian binary package (format 2.0)".to_string()
1358 } else if looks_like_rpm(bytes, path) {
1359 "RPM package".to_string()
1360 } else if looks_like_squashfs(bytes, path) {
1361 "Squashfs filesystem".to_string()
1362 } else if looks_like_gzip(bytes) {
1363 "gzip compressed data".to_string()
1364 } else if looks_like_bzip2(bytes) {
1365 "bzip2 compressed data".to_string()
1366 } else if looks_like_xz(bytes) {
1367 "XZ compressed data".to_string()
1368 } else if is_zip_archive(bytes) {
1369 "Zip archive data".to_string()
1370 } else if lower_extension(path).as_deref() == Some("gem") {
1371 "POSIX tar archive".to_string()
1372 } else if let Some(file_type) = format_based_file_type(detected_format) {
1373 file_type
1374 } else {
1375 "archive data".to_string()
1376 }
1377}
1378
1379fn script_file_type(programming_language: Option<&str>, bytes: &[u8]) -> String {
1380 let suffix = text_executable_label(bytes);
1381
1382 match programming_language {
1383 Some("Python") => format!("python script, {suffix}"),
1384 Some("Ruby") => format!("ruby script, {suffix}"),
1385 Some("Perl") => format!("perl script, {suffix}"),
1386 Some("PHP") => format!("php script, {suffix}"),
1387 Some("Shell") => format!("shell script, {suffix}"),
1388 Some("Bash") => format!("bash script, {suffix}"),
1389 Some("Zsh") => format!("zsh script, {suffix}"),
1390 Some("Fish") => format!("fish script, {suffix}"),
1391 Some("Ksh") => format!("ksh script, {suffix}"),
1392 Some("JavaScript") => format!("javascript script, {suffix}"),
1393 Some("TypeScript") => format!("typescript script, {suffix}"),
1394 Some("PowerShell") => format!("powershell script, {suffix}"),
1395 Some("Awk") => format!("awk script, {suffix}"),
1396 _ => format!("script, {suffix}"),
1397 }
1398}
1399
1400fn source_file_type(programming_language: Option<&str>, bytes: &[u8]) -> String {
1401 let suffix = text_label(bytes);
1402 match programming_language {
1403 Some("C") => format!("C source, {suffix}"),
1404 Some("C++") => format!("C++ source, {suffix}"),
1405 Some("Java") => format!("Java source, {suffix}"),
1406 Some("C#") => format!("C# source, {suffix}"),
1407 Some("F#") => format!("F# source, {suffix}"),
1408 Some("Go") => format!("Go source, {suffix}"),
1409 Some("Rust") => format!("Rust source, {suffix}"),
1410 Some("Starlark") => format!("Starlark source, {suffix}"),
1411 Some("CMake") => format!("CMake source, {suffix}"),
1412 Some("Meson") => format!("Meson source, {suffix}"),
1413 Some("Nix") => format!("Nix source, {suffix}"),
1414 Some("Groovy") => format!("Groovy source, {suffix}"),
1415 Some("Makefile") => format!("Makefile source, {suffix}"),
1416 Some("Dockerfile") => format!("Dockerfile source, {suffix}"),
1417 Some("Jamfile") => format!("Jamfile source, {suffix}"),
1418 Some("Batchfile") => format!("Batchfile source, {suffix}"),
1419 Some(language) => format!("{language} source, {suffix}"),
1420 None => text_file_type(bytes),
1421 }
1422}
1423
1424fn text_file_type(bytes: &[u8]) -> String {
1425 text_label(bytes).to_string()
1426}
1427
1428fn text_label(bytes: &[u8]) -> &'static str {
1429 if std::str::from_utf8(bytes).is_ok() {
1430 if bytes.contains(&b'\n') {
1431 "UTF-8 Unicode text"
1432 } else {
1433 "UTF-8 Unicode text, with no line terminators"
1434 }
1435 } else if bytes.contains(&b'\n') {
1436 "text"
1437 } else {
1438 "text, with no line terminators"
1439 }
1440}
1441
1442fn text_executable_label(bytes: &[u8]) -> &'static str {
1443 if std::str::from_utf8(bytes).is_ok() {
1444 if bytes.contains(&b'\n') {
1445 "UTF-8 Unicode text executable"
1446 } else {
1447 "UTF-8 Unicode text executable, with no line terminators"
1448 }
1449 } else if bytes.contains(&b'\n') {
1450 "text executable"
1451 } else {
1452 "text executable, with no line terminators"
1453 }
1454}
1455
1456fn supported_image_metadata_format(
1457 ext: Option<&str>,
1458 detected_format: FileFormat,
1459) -> Option<ImageFormat> {
1460 match ext {
1461 Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg),
1462 Some("png") => Some(ImageFormat::Png),
1463 Some("tif" | "tiff") => Some(ImageFormat::Tiff),
1464 Some("webp") => Some(ImageFormat::WebP),
1465 _ => match detected_format.media_type() {
1466 "image/jpeg" => Some(ImageFormat::Jpeg),
1467 "image/png" => Some(ImageFormat::Png),
1468 "image/tiff" => Some(ImageFormat::Tiff),
1469 "image/webp" => Some(ImageFormat::WebP),
1470 _ => None,
1471 },
1472 }
1473}
1474
1475fn should_skip_binary_string_extraction(
1476 path: &Path,
1477 bytes: &[u8],
1478 detected_format: FileFormat,
1479) -> bool {
1480 matches!(lower_extension(path).as_deref(), Some("pdf"))
1481 || supported_image_metadata_format(lower_extension(path).as_deref(), detected_format)
1482 .is_some()
1483 || (matches!(
1484 detected_format.kind(),
1485 FileFormatKind::Audio | FileFormatKind::Image | FileFormatKind::Video
1486 ) && !is_textual_format(detected_format))
1487 || media_mime_from_content(bytes).is_some()
1488 || is_zip_archive(bytes)
1489 || looks_like_gzip(bytes)
1490 || looks_like_bzip2(bytes)
1491 || looks_like_xz(bytes)
1492 || looks_like_deb(bytes, path)
1493 || looks_like_rpm(bytes, path)
1494 || looks_like_squashfs(bytes, path)
1495}
1496
1497fn should_skip_large_opaque_binary_text_extraction(
1498 _path: &Path,
1499 bytes: &[u8],
1500 detected_format: FileFormat,
1501) -> bool {
1502 is_large_opaque_binary_candidate(bytes, detected_format)
1503 && !sample_has_promising_printable_strings(bytes)
1504}
1505
1506fn is_large_opaque_binary_candidate(bytes: &[u8], detected_format: FileFormat) -> bool {
1507 bytes.len() >= LARGE_OPAQUE_BINARY_SKIP_BYTES
1508 && !is_textual_format(detected_format)
1509 && !matches!(
1510 detected_format.kind(),
1511 FileFormatKind::Archive
1512 | FileFormatKind::Compressed
1513 | FileFormatKind::Package
1514 | FileFormatKind::Audio
1515 | FileFormatKind::Image
1516 | FileFormatKind::Video
1517 )
1518}
1519
1520fn sampled_printable_window_ranges(len: usize) -> Vec<(usize, usize)> {
1521 const SAMPLE_WINDOW_BYTES: usize = 64 * 1024;
1522
1523 let mut ranges = Vec::new();
1524 let mut push_range = |start: usize, end: usize| {
1525 if start < end && !ranges.contains(&(start, end)) {
1526 ranges.push((start, end));
1527 }
1528 };
1529
1530 push_range(0, len.min(SAMPLE_WINDOW_BYTES));
1531 if len > SAMPLE_WINDOW_BYTES * 2 {
1532 let mid_start = len / 2 - SAMPLE_WINDOW_BYTES / 2;
1533 let mid_end = (mid_start + SAMPLE_WINDOW_BYTES).min(len);
1534 push_range(mid_start, mid_end);
1535 }
1536 if len > SAMPLE_WINDOW_BYTES {
1537 push_range(len - SAMPLE_WINDOW_BYTES, len);
1538 }
1539
1540 ranges
1541}
1542
1543fn sample_has_promising_printable_strings(bytes: &[u8]) -> bool {
1544 let mut structured_signal_seen = false;
1545 let promising_license_windows = sampled_printable_window_ranges(bytes.len())
1546 .into_iter()
1547 .filter(|&(start, end)| {
1548 let window = &bytes[start..end];
1549 if has_strong_structured_text_signal(window) {
1550 structured_signal_seen = true;
1551 }
1552 has_license_or_notice_signal(window)
1553 })
1554 .count();
1555
1556 structured_signal_seen || promising_license_windows >= 2
1557}
1558
1559fn extract_sampled_printable_strings(bytes: &[u8]) -> String {
1560 let mut combined_lines = BTreeSet::new();
1561
1562 for (start, end) in sampled_printable_window_ranges(bytes.len()) {
1563 let window_text = extract_printable_strings(&bytes[start..end]);
1564 for line in window_text
1565 .lines()
1566 .map(str::trim)
1567 .filter(|line| !line.is_empty())
1568 {
1569 combined_lines.insert(line.to_string());
1570 }
1571 }
1572
1573 combined_lines.into_iter().collect::<Vec<_>>().join("\n")
1574}
1575
1576fn has_license_or_notice_signal(bytes: &[u8]) -> bool {
1577 let strings = extract_printable_strings(bytes);
1578 if strings.is_empty() {
1579 return false;
1580 }
1581
1582 let lower = strings.to_ascii_lowercase();
1583 [
1584 "copyright",
1585 "license",
1586 "licensed under",
1587 "all rights reserved",
1588 "permission is hereby granted",
1589 "redistribution and use",
1590 "spdx-license-identifier",
1591 ]
1592 .iter()
1593 .any(|marker| lower.contains(marker))
1594}
1595
1596fn has_strong_structured_text_signal(bytes: &[u8]) -> bool {
1597 let strings = extract_printable_strings(bytes);
1598 if strings.is_empty() {
1599 return false;
1600 }
1601
1602 let email_markers = strings.matches('@').count();
1603 let url_markers = strings.matches("http://").count() + strings.matches("https://").count();
1604
1605 email_markers + url_markers >= 3
1606}
1607
1608fn is_supported_image_container(bytes: &[u8], format: ImageFormat) -> bool {
1609 match format {
1610 ImageFormat::Png => bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
1611 ImageFormat::Jpeg => bytes.starts_with(&[0xff, 0xd8, 0xff]),
1612 ImageFormat::Tiff => bytes.starts_with(b"II\x2a\x00") || bytes.starts_with(b"MM\x00\x2a"),
1613 ImageFormat::WebP => {
1614 bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP"
1615 }
1616 _ => false,
1617 }
1618}
1619
1620fn extract_image_metadata_text(bytes: &[u8], format: ImageFormat) -> String {
1621 let mut values = Vec::new();
1622 values.extend(extract_exif_metadata_values(bytes));
1623 values.extend(extract_xmp_metadata_values(bytes, format));
1624 values_to_text(values)
1625}
1626
1627fn extract_exif_metadata_values(bytes: &[u8]) -> Vec<String> {
1628 let mut cursor = BufReader::new(Cursor::new(bytes));
1629 let exif = match exif::Reader::new().read_from_container(&mut cursor) {
1630 Ok(exif) => exif,
1631 Err(_) => return Vec::new(),
1632 };
1633
1634 let mut values = Vec::new();
1635 for field in exif.fields() {
1636 let rendered = match field.tag {
1637 exif::Tag::ImageDescription | exif::Tag::Copyright | exif::Tag::UserComment => {
1638 Some(field.display_value().with_unit(&exif).to_string())
1639 }
1640 exif::Tag::Artist => Some(format!(
1641 "Author: {}",
1642 field.display_value().with_unit(&exif)
1643 )),
1644 _ => None,
1645 };
1646
1647 if let Some(rendered) = rendered {
1648 values.push(rendered);
1649 }
1650 }
1651
1652 values
1653}
1654
1655fn extract_xmp_metadata_values(bytes: &[u8], format: ImageFormat) -> Vec<String> {
1656 let xmp = match extract_raw_xmp_packet(bytes, format) {
1657 Some(xmp) => xmp,
1658 None => return Vec::new(),
1659 };
1660
1661 parse_xmp_values(&xmp)
1662}
1663
1664fn extract_raw_xmp_packet(bytes: &[u8], format: ImageFormat) -> Option<Vec<u8>> {
1665 let reader = ImageReader::with_format(BufReader::new(Cursor::new(bytes)), format);
1666 if let Ok(mut decoder) = reader.into_decoder()
1667 && let Ok(Some(xmp)) = decoder.xmp_metadata()
1668 {
1669 return Some(xmp);
1670 }
1671
1672 match format {
1673 ImageFormat::Png => extract_png_xmp_packet(bytes),
1674 _ => None,
1675 }
1676}
1677
1678fn extract_png_xmp_packet(bytes: &[u8]) -> Option<Vec<u8>> {
1679 const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1680
1681 if bytes.len() < PNG_SIGNATURE.len() || &bytes[..PNG_SIGNATURE.len()] != PNG_SIGNATURE {
1682 return None;
1683 }
1684
1685 let mut offset = PNG_SIGNATURE.len();
1686 while offset + 12 <= bytes.len() {
1687 let length = u32::from_be_bytes([
1688 bytes[offset],
1689 bytes[offset + 1],
1690 bytes[offset + 2],
1691 bytes[offset + 3],
1692 ]) as usize;
1693 let chunk_start = offset + 8;
1694 let chunk_end = chunk_start + length;
1695 if chunk_end + 4 > bytes.len() {
1696 return None;
1697 }
1698
1699 let chunk_type = &bytes[offset + 4..offset + 8];
1700 if chunk_type == b"iTXt" {
1701 let data = &bytes[chunk_start..chunk_end];
1702 if let Some(xmp) = parse_png_itxt_xmp(data) {
1703 return Some(xmp);
1704 }
1705 }
1706
1707 offset = chunk_end + 4;
1708 }
1709
1710 None
1711}
1712
1713fn parse_png_itxt_xmp(data: &[u8]) -> Option<Vec<u8>> {
1714 const XMP_KEYWORD: &[u8] = b"XML:com.adobe.xmp";
1715
1716 let keyword_end = data.iter().position(|&b| b == 0)?;
1717 if &data[..keyword_end] != XMP_KEYWORD {
1718 return None;
1719 }
1720
1721 let mut cursor = keyword_end + 1;
1722 let compression_flag = *data.get(cursor)?;
1723 cursor += 1;
1724 let compression_method = *data.get(cursor)?;
1725 cursor += 1;
1726 if compression_flag > 1 || (compression_flag == 1 && compression_method != 0) {
1727 return None;
1728 }
1729
1730 let language_end = cursor + data[cursor..].iter().position(|&b| b == 0)?;
1731 cursor = language_end + 1;
1732
1733 let translated_end = cursor + data[cursor..].iter().position(|&b| b == 0)?;
1734 cursor = translated_end + 1;
1735
1736 let text_bytes = &data[cursor..];
1737 if compression_flag == 1 {
1738 let mut decoder = ZlibDecoder::new(text_bytes);
1739 let mut decoded = Vec::new();
1740 decoder.read_to_end(&mut decoded).ok()?;
1741 Some(decoded)
1742 } else {
1743 Some(text_bytes.to_vec())
1744 }
1745}
1746
1747fn parse_xmp_values(xmp: &[u8]) -> Vec<String> {
1748 let mut reader = XmlReader::from_reader(xmp);
1749 reader.config_mut().trim_text(true);
1750
1751 let mut buf = Vec::new();
1752 let mut stack: Vec<String> = Vec::new();
1753 let mut values = Vec::new();
1754
1755 loop {
1756 match reader.read_event_into(&mut buf) {
1757 Ok(Event::Start(e)) => {
1758 stack.push(local_xml_name(e.name().as_ref()));
1759 }
1760 Ok(Event::End(_)) => {
1761 stack.pop();
1762 }
1763 Ok(Event::Empty(_)) => {}
1764 Ok(Event::Text(text)) => {
1765 if let Some(field) = stack
1766 .iter()
1767 .rev()
1768 .find_map(|name| allowed_xmp_field(name.as_str()))
1769 && let Ok(decoded) = text.decode()
1770 {
1771 let decoded = decoded.into_owned();
1772 if !decoded.trim().is_empty() {
1773 values.push(format_xmp_value(field, &decoded));
1774 }
1775 }
1776 }
1777 Ok(Event::CData(text)) => {
1778 if let Some(field) = stack
1779 .iter()
1780 .rev()
1781 .find_map(|name| allowed_xmp_field(name.as_str()))
1782 && let Ok(decoded) = text.decode()
1783 {
1784 let decoded = decoded.into_owned();
1785 if !decoded.trim().is_empty() {
1786 values.push(format_xmp_value(field, &decoded));
1787 }
1788 }
1789 }
1790 Ok(Event::Eof) | Err(_) => break,
1791 _ => {}
1792 }
1793 buf.clear();
1794 }
1795
1796 values
1797}
1798
1799fn local_xml_name(name: &[u8]) -> String {
1800 let name = std::str::from_utf8(name).unwrap_or_default();
1801 name.rsplit(':').next().unwrap_or(name).to_string()
1802}
1803
1804fn allowed_xmp_field(name: &str) -> Option<&'static str> {
1805 match name {
1806 "creator" => Some("creator"),
1807 "rights" => Some("rights"),
1808 "description" => Some("description"),
1809 "title" => Some("title"),
1810 "subject" => Some("subject"),
1811 "UsageTerms" => Some("usage_terms"),
1812 "WebStatement" => Some("web_statement"),
1813 _ => None,
1814 }
1815}
1816
1817fn format_xmp_value(field: &str, value: &str) -> String {
1818 match field {
1819 "creator" => format!("Author: {value}"),
1820 _ => value.to_string(),
1821 }
1822}
1823
1824fn values_to_text(values: Vec<String>) -> String {
1825 let mut seen = BTreeSet::new();
1826 let mut lines = Vec::new();
1827 let mut total_bytes = 0usize;
1828
1829 for value in values {
1830 if lines.len() >= MAX_IMAGE_METADATA_VALUES {
1831 break;
1832 }
1833
1834 let normalized = normalize_metadata_value(&value);
1835 if normalized.is_empty() || !seen.insert(normalized.clone()) {
1836 continue;
1837 }
1838
1839 let added_bytes = normalized.len() + usize::from(!lines.is_empty());
1840 if total_bytes + added_bytes > MAX_IMAGE_METADATA_TEXT_BYTES {
1841 break;
1842 }
1843
1844 total_bytes += added_bytes;
1845 lines.push(normalized);
1846 }
1847
1848 lines.join("\n")
1849}
1850
1851fn normalize_metadata_value(value: &str) -> String {
1852 value
1853 .chars()
1854 .filter(|&ch| ch != '\0')
1855 .collect::<String>()
1856 .split_whitespace()
1857 .collect::<Vec<_>>()
1858 .join(" ")
1859 .trim()
1860 .to_string()
1861}
1862
1863fn extract_pdf_text(path: &Path, bytes: &[u8]) -> (String, Option<String>) {
1864 if bytes.len() < 5 || &bytes[..5] != b"%PDF-" {
1865 return (String::new(), None);
1866 }
1867
1868 let mut failures = Vec::new();
1869 let mut saw_success = false;
1870
1871 let extracted = catch_unwind(AssertUnwindSafe(
1872 || -> Result<String, Box<dyn std::error::Error>> {
1873 let mut document = pdf_oxide::document::PdfDocument::from_bytes(bytes.to_vec())?;
1874 extract_first_pdf_page_text(&mut document)
1875 },
1876 ));
1877 match extracted {
1878 Ok(Ok(text)) => {
1879 saw_success = true;
1880 if let Some(normalized) = normalize_pdf_text(text) {
1881 return (normalized, None);
1882 }
1883 }
1884 Ok(Err(err)) => failures.push(format!("from-bytes first-page: {err}")),
1885 Err(payload) => failures.push(format!(
1886 "from-bytes first-page panic: {}",
1887 panic_payload_to_string(payload.as_ref())
1888 )),
1889 }
1890
1891 let extracted = catch_unwind(AssertUnwindSafe(
1892 || -> Result<String, Box<dyn std::error::Error>> {
1893 let mut document = pdf_oxide::document::PdfDocument::open(path)?;
1894 extract_pdf_text_from_document(&mut document)
1895 },
1896 ));
1897 match extracted {
1898 Ok(Ok(text)) => {
1899 saw_success = true;
1900 if let Some(normalized) = normalize_pdf_text(text) {
1901 return (normalized, None);
1902 }
1903 }
1904 Ok(Err(err)) => failures.push(format!("open full-document: {err}")),
1905 Err(payload) => failures.push(format!(
1906 "open full-document panic: {}",
1907 panic_payload_to_string(payload.as_ref())
1908 )),
1909 }
1910
1911 let extracted = catch_unwind(AssertUnwindSafe(
1912 || -> Result<String, Box<dyn std::error::Error>> {
1913 let mut document = pdf_oxide::document::PdfDocument::from_bytes(bytes.to_vec())?;
1914 extract_pdf_text_from_document(&mut document)
1915 },
1916 ));
1917 match extracted {
1918 Ok(Ok(text)) => {
1919 saw_success = true;
1920 if let Some(normalized) = normalize_pdf_text(text) {
1921 return (normalized, None);
1922 }
1923 }
1924 Ok(Err(err)) => failures.push(format!("from-bytes full-document: {err}")),
1925 Err(payload) => failures.push(format!(
1926 "from-bytes full-document panic: {}",
1927 panic_payload_to_string(payload.as_ref())
1928 )),
1929 }
1930
1931 if saw_success || is_non_actionable_pdf_failure(&failures) {
1932 (String::new(), None)
1933 } else {
1934 (
1935 String::new(),
1936 Some(format!(
1937 "PDF text extraction failed after {} attempts: {}",
1938 failures.len(),
1939 failures.join("; ")
1940 )),
1941 )
1942 }
1943}
1944
1945fn is_non_actionable_pdf_failure(failures: &[String]) -> bool {
1946 !failures.is_empty()
1947 && failures.iter().all(|failure| {
1948 failure.contains("requires a password")
1949 || failure.contains("Encrypt dictionary missing /O")
1950 || failure.contains("Encrypt dictionary missing /U")
1951 || failure.contains("security handler cannot be found")
1952 || failure.contains("Invalid cross-reference table")
1953 })
1954}
1955
1956fn panic_payload_to_string(payload: &(dyn std::any::Any + Send)) -> String {
1957 if let Some(message) = payload.downcast_ref::<&str>() {
1958 (*message).to_string()
1959 } else if let Some(message) = payload.downcast_ref::<String>() {
1960 message.clone()
1961 } else {
1962 "unknown panic payload".to_string()
1963 }
1964}
1965
1966fn extract_first_pdf_page_text(
1967 document: &mut pdf_oxide::document::PdfDocument,
1968) -> Result<String, Box<dyn std::error::Error>> {
1969 if document.page_count()? == 0 {
1970 return Ok(String::new());
1971 }
1972
1973 let extracted_text = document.extract_text(0)?;
1974 let markdown_text =
1975 document.to_markdown(0, &pdf_oxide::converters::ConversionOptions::default())?;
1976 if pdf_markdown_heading_lines(&markdown_text).is_empty() {
1977 return Ok(extracted_text);
1978 }
1979
1980 let pipeline_text =
1981 document.to_plain_text(0, &pdf_oxide::converters::ConversionOptions::default())?;
1982
1983 Ok(merge_pdf_first_page_text(
1984 &extracted_text,
1985 &markdown_text,
1986 &pipeline_text,
1987 ))
1988}
1989
1990fn extract_pdf_text_from_document(
1991 document: &mut pdf_oxide::document::PdfDocument,
1992) -> Result<String, Box<dyn std::error::Error>> {
1993 Ok(document.to_plain_text_all(&pdf_oxide::converters::ConversionOptions::default())?)
1994}
1995
1996fn normalize_pdf_text(text: String) -> Option<String> {
1997 let normalized = text.replace(['\r', '\u{0c}'], "\n");
1998 (!normalized.trim().is_empty()).then_some(normalized)
1999}
2000
2001fn merge_pdf_first_page_text(
2002 _extracted_text: &str,
2003 markdown_text: &str,
2004 pipeline_text: &str,
2005) -> String {
2006 let pipeline = pipeline_text.trim();
2007 if pipeline.is_empty() {
2008 return String::new();
2009 }
2010
2011 let prefix = pdf_first_page_heading_prefix(markdown_text);
2012 let Some(prefix) = prefix else {
2013 return pipeline_text.to_string();
2014 };
2015
2016 if pdf_text_contains_heading_prefix(pipeline, &prefix) {
2017 pipeline_text.to_string()
2018 } else {
2019 format!("{prefix}\n\n{pipeline}")
2020 }
2021}
2022
2023fn pdf_text_contains_heading_prefix(text: &str, prefix: &str) -> bool {
2024 normalize_pdf_heading_comparison_text(text)
2025 .contains(&normalize_pdf_heading_comparison_text(prefix))
2026}
2027
2028fn normalize_pdf_heading_comparison_text(text: &str) -> String {
2029 text.split_whitespace()
2030 .map(|part| part.to_ascii_lowercase())
2031 .collect::<Vec<_>>()
2032 .join(" ")
2033}
2034
2035fn pdf_first_page_heading_prefix(markdown_text: &str) -> Option<String> {
2036 let mut lines = Vec::new();
2037
2038 for line in pdf_markdown_heading_lines(markdown_text) {
2039 push_unique_line(&mut lines, line);
2040 }
2041
2042 (!lines.is_empty()).then(|| lines.join("\n"))
2043}
2044
2045fn pdf_markdown_heading_lines(text: &str) -> Vec<String> {
2046 text.lines()
2047 .map(str::trim)
2048 .filter_map(|line| line.strip_prefix('#').map(str::trim_start))
2049 .map(|line| line.trim_matches('#').trim())
2050 .filter(|line| !line.is_empty())
2051 .filter(|line| !looks_like_numbered_section_heading(line))
2052 .take(4)
2053 .map(ToOwned::to_owned)
2054 .collect()
2055}
2056
2057fn push_unique_line(lines: &mut Vec<String>, line: String) {
2058 if !lines.iter().any(|existing| existing == &line) {
2059 lines.push(line);
2060 }
2061}
2062
2063fn looks_like_numbered_section_heading(line: &str) -> bool {
2064 let mut chars = line.chars();
2065 let Some(first) = chars.next() else {
2066 return false;
2067 };
2068
2069 if !first.is_ascii_digit() {
2070 return false;
2071 }
2072
2073 matches!(chars.next(), Some('.'))
2074}
2075
2076fn is_zip_archive(bytes: &[u8]) -> bool {
2077 bytes.starts_with(b"PK\x03\x04")
2078 || bytes.starts_with(b"PK\x05\x06")
2079 || bytes.starts_with(b"PK\x07\x08")
2080}
2081
2082pub fn extract_printable_strings(bytes: &[u8]) -> String {
2083 const MIN_LEN: usize = 4;
2084 const MIN_OUTPUT_BYTES: usize = 2_000_000;
2085 const MAX_OUTPUT_BYTES_CAP: usize = 16_000_000;
2086
2087 let max_output_bytes = bytes.len().clamp(MIN_OUTPUT_BYTES, MAX_OUTPUT_BYTES_CAP);
2088
2089 fn is_printable_ascii(b: u8) -> bool {
2090 matches!(b, 0x20..=0x7E)
2091 }
2092
2093 let mut out = String::new();
2094 let mut run: Vec<u8> = Vec::new();
2095
2096 let flush_run = |out: &mut String, run: &mut Vec<u8>| {
2097 if run.len() >= MIN_LEN {
2098 if !out.is_empty() {
2099 out.push('\n');
2100 }
2101 out.push_str(&String::from_utf8_lossy(run));
2102 }
2103 run.clear();
2104 };
2105
2106 for &b in bytes {
2107 if is_printable_ascii(b) {
2108 run.push(b);
2109 } else {
2110 flush_run(&mut out, &mut run);
2111 if out.len() >= max_output_bytes {
2112 return out;
2113 }
2114 }
2115 }
2116 flush_run(&mut out, &mut run);
2117 if out.len() >= max_output_bytes {
2118 return out;
2119 }
2120
2121 for start in 0..=1 {
2122 run.clear();
2123 let mut i = start;
2124 while i + 1 < bytes.len() {
2125 let b0 = bytes[i];
2126 let b1 = bytes[i + 1];
2127 let (ch, zero) = if start == 0 { (b0, b1) } else { (b1, b0) };
2128 if is_printable_ascii(ch) && zero == 0 {
2129 run.push(ch);
2130 } else {
2131 flush_run(&mut out, &mut run);
2132 if out.len() >= max_output_bytes {
2133 return out;
2134 }
2135 }
2136 i += 2;
2137 }
2138 flush_run(&mut out, &mut run);
2139 if out.len() >= max_output_bytes {
2140 return out;
2141 }
2142 }
2143
2144 out
2145}
2146
2147#[cfg(test)]
2148mod tests {
2149 use std::path::Path;
2150
2151 use super::{
2152 ExtractedTextKind, LARGE_OPAQUE_BINARY_SKIP_BYTES, classify_file_info,
2153 extract_printable_strings, extract_text_for_detection,
2154 extract_text_for_detection_with_diagnostics, is_non_actionable_pdf_failure,
2155 normalize_mime_type, normalize_pdf_heading_comparison_text,
2156 windows_metadata_or_empty_result,
2157 };
2158
2159 #[test]
2160 fn test_extract_text_for_detection_skips_jar_archives() {
2161 let path = Path::new(
2162 "testdata/license-golden/datadriven/lic1/do-not_detect-licenses-in-archive.jar",
2163 );
2164 let bytes = std::fs::read(path).expect("failed to read jar fixture");
2165
2166 let (text, kind) = extract_text_for_detection(path, &bytes);
2167
2168 assert!(text.is_empty());
2169 assert_eq!(kind, ExtractedTextKind::None);
2170 }
2171
2172 #[test]
2173 fn test_extract_text_for_detection_reads_pdf_fixture_text() {
2174 let path = Path::new("testdata/license-golden/datadriven/lic2/bsd-new_156.pdf");
2175 let bytes = std::fs::read(path).expect("failed to read pdf fixture");
2176
2177 let (text, kind) = extract_text_for_detection(path, &bytes);
2178
2179 assert_eq!(kind, ExtractedTextKind::Pdf);
2180 assert!(text.contains("Redistribution and use in source and binary forms"));
2181 }
2182
2183 #[test]
2184 fn test_extract_text_for_detection_prefers_first_pdf_page_before_full_document() {
2185 let path =
2186 Path::new("testdata/license-golden/datadriven/lic4/should_detect_something_5.pdf");
2187 let bytes = std::fs::read(path).expect("failed to read pdf fixture");
2188
2189 let (text, kind) = extract_text_for_detection(path, &bytes);
2190
2191 assert_eq!(kind, ExtractedTextKind::Pdf);
2192 assert!(text.contains("SUN INDUSTRY STANDARDS SOURCE LICENSE"));
2193 assert!(!text.contains("DISCLAIMER OF WARRANTY"));
2194 }
2195
2196 #[test]
2197 fn test_extract_text_for_detection_does_not_duplicate_pdf_heading_prefix() {
2198 let path =
2199 Path::new("testdata/license-golden/datadriven/lic4/should_detect_something_5.pdf");
2200 let bytes = std::fs::read(path).expect("failed to read pdf fixture");
2201
2202 let (text, kind) = extract_text_for_detection(path, &bytes);
2203
2204 assert_eq!(kind, ExtractedTextKind::Pdf);
2205
2206 let normalized = normalize_pdf_heading_comparison_text(&text);
2207 let heading =
2208 normalize_pdf_heading_comparison_text("SUN INDUSTRY STANDARDS SOURCE LICENSE");
2209 assert_eq!(normalized.matches(&heading).count(), 1);
2210 }
2211
2212 #[test]
2213 fn test_extract_text_for_detection_reads_pdf_fixture_without_pdf_extension() {
2214 let path = Path::new("testdata/license-golden/datadriven/lic2/bsd-new_156.pdf");
2215 let bytes = std::fs::read(path).expect("failed to read pdf fixture");
2216
2217 let (text, kind) = extract_text_for_detection(Path::new("renamed.bin"), &bytes);
2218
2219 assert_eq!(kind, ExtractedTextKind::Pdf);
2220 assert!(text.contains("Redistribution and use in source and binary forms"));
2221 }
2222
2223 #[test]
2224 fn test_extract_text_for_detection_reports_terminal_pdf_failure() {
2225 let malformed = b"%PDF-1.7\nthis is not a valid pdf object graph\n";
2226
2227 let (text, kind, scan_error) =
2228 extract_text_for_detection_with_diagnostics(Path::new("broken.pdf"), malformed);
2229
2230 assert!(text.is_empty());
2231 assert_eq!(kind, ExtractedTextKind::None);
2232 let scan_error = scan_error.expect("terminal pdf failure should be surfaced");
2233 assert!(scan_error.contains("PDF text extraction failed after"));
2234 }
2235
2236 #[test]
2237 fn test_extract_text_for_detection_skips_large_opaque_binary_blobs() {
2238 let bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES + 8];
2239
2240 let (text, kind) = extract_text_for_detection(Path::new("model.bin"), &bytes);
2241
2242 assert!(text.is_empty());
2243 assert_eq!(kind, ExtractedTextKind::None);
2244 }
2245
2246 #[test]
2247 fn test_extract_text_for_detection_keeps_large_binaries_with_promising_strings() {
2248 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES + 8];
2249 let text = b"Copyright 2026 Example Project!!!";
2250 bytes[..text.len()].copy_from_slice(text);
2251 let second_offset = LARGE_OPAQUE_BINARY_SKIP_BYTES / 2;
2252 bytes[second_offset..second_offset + text.len()].copy_from_slice(text);
2253
2254 let (text, kind) = extract_text_for_detection(Path::new("weights.bin"), &bytes);
2255
2256 assert_ne!(kind, ExtractedTextKind::None);
2257 assert!(text.contains("Copyright 2026 Example Project"));
2258 }
2259
2260 #[test]
2261 fn test_extract_text_for_detection_skips_large_binary_with_unstructured_runs() {
2262 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES + 8];
2263 let noise = b"(c) $1234567890ABCDEF[]{}--==++";
2264 bytes[..noise.len()].copy_from_slice(noise);
2265 let second_offset = LARGE_OPAQUE_BINARY_SKIP_BYTES / 2;
2266 bytes[second_offset..second_offset + noise.len()].copy_from_slice(noise);
2267
2268 let (text, kind) = extract_text_for_detection(Path::new("tensor.bin"), &bytes);
2269
2270 assert!(text.is_empty());
2271 assert_eq!(kind, ExtractedTextKind::None);
2272 }
2273
2274 #[test]
2275 fn test_extract_text_for_detection_uses_windows_executable_metadata() {
2276 let path = Path::new("testdata/compiled-binary-golden/win_pe/libiconv2.dll");
2277 let bytes = std::fs::read(path).expect("read PE fixture");
2278
2279 let (text, kind) = extract_text_for_detection(path, &bytes);
2280
2281 assert_eq!(kind, ExtractedTextKind::BinaryStrings);
2282 assert!(text.contains("License: This program is free software"));
2283 assert!(text.contains("LegalCopyright:"));
2284 }
2285
2286 #[test]
2287 fn test_extract_text_for_detection_keeps_windows_metadata_for_large_pe_without_sampled_signal()
2288 {
2289 let path = Path::new("testdata/compiled-binary-golden/win_pe/libiconv2.dll");
2290 let mut bytes = std::fs::read(path).expect("read PE fixture");
2291 bytes.resize(LARGE_OPAQUE_BINARY_SKIP_BYTES + 8, 0);
2292
2293 let (text, kind) = extract_text_for_detection(path, &bytes);
2294
2295 assert_ne!(kind, ExtractedTextKind::None);
2296 assert!(!text.trim().is_empty());
2297 }
2298
2299 #[test]
2300 fn test_windows_metadata_or_empty_result_preserves_metadata() {
2301 let (text, kind, scan_error) =
2302 windows_metadata_or_empty_result(Some("LegalCopyright: Example Corp".to_string()));
2303
2304 assert_eq!(kind, ExtractedTextKind::WindowsExecutableMetadata);
2305 assert_eq!(text, "LegalCopyright: Example Corp");
2306 assert!(scan_error.is_none());
2307 }
2308
2309 #[test]
2310 fn test_extract_text_for_detection_skips_large_binary_with_single_isolated_string_run() {
2311 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES + 8];
2312 let text = b"Copyright 2026 Example Project!!!";
2313 bytes[..text.len()].copy_from_slice(text);
2314
2315 let (text, kind) = extract_text_for_detection(Path::new("opaque.bin"), &bytes);
2316
2317 assert!(text.is_empty());
2318 assert_eq!(kind, ExtractedTextKind::None);
2319 }
2320
2321 #[test]
2322 fn test_extract_text_for_detection_keeps_large_binary_with_single_contact_rich_window() {
2323 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES + 8];
2324 let text = b"Andreas Schneider <asn@redhat.com> Rob Crittenden (rcritten@redhat.com) Mr. Sam <sam@email-scan.com> https://publicsuffix.org/ http://tukaani.org/xz/";
2325 bytes[..text.len()].copy_from_slice(text);
2326
2327 let (text, kind) = extract_text_for_detection(Path::new("rootfs.bin"), &bytes);
2328
2329 assert_ne!(kind, ExtractedTextKind::None);
2330 assert!(text.contains("asn@redhat.com"));
2331 assert!(text.contains("https://publicsuffix.org/"));
2332 }
2333
2334 #[test]
2335 fn test_non_actionable_pdf_failures_are_suppressed() {
2336 assert!(is_non_actionable_pdf_failure(&[
2337 "from-bytes first-page: PDF is encrypted and requires a password".to_string(),
2338 "open full-document: PDF is encrypted and requires a password".to_string(),
2339 ]));
2340 assert!(is_non_actionable_pdf_failure(&[
2341 "from-bytes first-page: Invalid cross-reference table".to_string(),
2342 "open full-document: Invalid cross-reference table".to_string(),
2343 ]));
2344 assert!(is_non_actionable_pdf_failure(&[
2345 "from-bytes first-page: Invalid PDF: Encrypt dictionary missing /O".to_string(),
2346 "open full-document: Invalid PDF: security handler cannot be found".to_string(),
2347 ]));
2348 assert!(!is_non_actionable_pdf_failure(&[
2349 "from-bytes first-page: some other parser failure".to_string(),
2350 ]));
2351 }
2352
2353 #[test]
2354 fn test_extract_text_for_detection_skips_zip_like_archives() {
2355 let zip_bytes = b"PK\x03\x04\x14\x00\x00\x00\x08\x00artifact";
2356
2357 let (whl_text, whl_kind) = extract_text_for_detection(Path::new("demo.whl"), zip_bytes);
2358 let (crate_text, crate_kind) =
2359 extract_text_for_detection(Path::new("demo.crate"), zip_bytes);
2360
2361 assert!(whl_text.is_empty());
2362 assert_eq!(whl_kind, ExtractedTextKind::None);
2363 assert!(crate_text.is_empty());
2364 assert_eq!(crate_kind, ExtractedTextKind::None);
2365 }
2366
2367 #[test]
2368 fn test_extract_text_for_detection_keeps_binary_strings_for_lib_fixtures() {
2369 let path =
2370 Path::new("testdata/copyright-golden/copyrights/copyright_php_lib-php_embed_lib.lib");
2371 let bytes = std::fs::read(path).expect("failed to read lib fixture");
2372
2373 let (text, kind) = extract_text_for_detection(path, &bytes);
2374
2375 assert_ne!(kind, ExtractedTextKind::None);
2376 assert!(text.contains("Copyright nexB and others (c) 2012"));
2377 }
2378
2379 #[test]
2380 fn test_extract_text_for_detection_reads_font_metadata() {
2381 let path = Path::new("testdata/font-fixtures/Lato-Bold.ttf");
2382 let bytes = std::fs::read(path).expect("failed to read font fixture");
2383
2384 let (text, kind) = extract_text_for_detection(path, &bytes);
2385
2386 assert_eq!(kind, ExtractedTextKind::FontMetadata);
2387 assert!(text.contains("License Description:"), "{text}");
2388 assert!(
2389 text.contains("Open Font License") || text.contains("OFL"),
2390 "{text}"
2391 );
2392 assert!(text.contains("Lato"), "{text}");
2393 }
2394
2395 #[test]
2396 fn test_extract_printable_strings_scales_cap_for_medium_binary_files() {
2397 let bytes = b"abcd\0".repeat(525_000);
2398
2399 let text = extract_printable_strings(&bytes);
2400
2401 assert!(
2402 text.len() > 2_000_000,
2403 "unexpected truncation at {}",
2404 text.len()
2405 );
2406 assert!(text.ends_with("abcd"));
2407 }
2408
2409 #[test]
2410 fn test_extract_text_for_detection_decodes_svg_fixture_text() {
2411 let path = Path::new(
2412 "testdata/license-golden/datadriven/external/fossology-tests/Public-domain/biohazard.svg",
2413 );
2414 let bytes = std::fs::read(path).expect("failed to read svg fixture");
2415
2416 let (text, kind) = extract_text_for_detection(path, &bytes);
2417
2418 assert_eq!(kind, ExtractedTextKind::Decoded);
2419 assert!(text.contains("creativecommons.org/licenses/publicdomain"));
2420 }
2421
2422 #[test]
2423 fn test_extract_text_for_detection_decodes_rtf_fixture_text() {
2424 let path = Path::new(
2425 "testdata/license-golden/datadriven/external/fossology-tests/LGPL/License.rtf",
2426 );
2427 let bytes = std::fs::read(path).expect("failed to read rtf fixture");
2428
2429 let (text, kind) = extract_text_for_detection(path, &bytes);
2430
2431 assert_eq!(kind, ExtractedTextKind::Decoded);
2432 assert!(text.contains("GNU Lesser General Public"));
2433 assert!(text.contains("version"));
2434 assert!(text.contains("2.1 of the License"));
2435 }
2436
2437 #[test]
2438 fn test_normalize_mime_type_prefers_text_for_textual_video_guess() {
2439 assert_eq!(
2440 normalize_mime_type(
2441 Path::new("main.ts"),
2442 b"export const answer = 42;\n",
2443 Some("TypeScript"),
2444 "video/mp2t",
2445 ),
2446 "text/plain"
2447 );
2448 }
2449
2450 #[test]
2451 fn test_normalize_mime_type_prefers_text_for_octet_stream_source_guess() {
2452 assert_eq!(
2453 normalize_mime_type(
2454 Path::new("main.js"),
2455 b"console.log('hello');\n",
2456 Some("JavaScript"),
2457 "application/octet-stream",
2458 ),
2459 "text/plain"
2460 );
2461 }
2462
2463 #[test]
2464 fn test_normalize_mime_type_preserves_binary_video_guess() {
2465 assert_eq!(
2466 normalize_mime_type(
2467 Path::new("main.ts"),
2468 &[0, 159, 146, 150, 0, 1, 2, 3],
2469 Some("TypeScript"),
2470 "video/mp2t",
2471 ),
2472 "video/mp2t"
2473 );
2474 }
2475
2476 #[test]
2477 fn test_normalize_mime_type_preserves_short_binary_octet_stream_guess() {
2478 assert_eq!(
2479 normalize_mime_type(
2480 Path::new("main.ts"),
2481 &[0, 159, 146, 150],
2482 Some("TypeScript"),
2483 "application/octet-stream",
2484 ),
2485 "application/octet-stream"
2486 );
2487 }
2488
2489 #[test]
2490 fn test_classify_file_info_marks_empty_files_as_text_not_source() {
2491 let classification = classify_file_info(Path::new("test.txt"), b"");
2492
2493 assert_eq!(classification.mime_type, "inode/x-empty");
2494 assert_eq!(classification.file_type, "empty");
2495 assert!(!classification.is_binary);
2496 assert!(classification.is_text);
2497 assert!(!classification.is_source);
2498 assert_eq!(classification.programming_language, None);
2499 }
2500
2501 #[test]
2502 fn test_classify_file_info_keeps_json_out_of_programming_language() {
2503 let classification = classify_file_info(Path::new("package.json"), br#"{"name":"demo"}"#);
2504
2505 assert_eq!(classification.mime_type, "application/json");
2506 assert_eq!(classification.file_type, "JSON text data");
2507 assert!(classification.is_text);
2508 assert!(!classification.is_source);
2509 assert_eq!(classification.programming_language, None);
2510 }
2511
2512 #[test]
2513 fn test_classify_file_info_does_not_label_invalid_json_text_as_json() {
2514 let classification =
2515 classify_file_info(Path::new("broken.json"), b"{ definitely not json\n");
2516
2517 assert_eq!(classification.mime_type, "text/plain");
2518 assert_eq!(classification.file_type, "UTF-8 Unicode text");
2519 assert!(classification.is_text);
2520 assert!(!classification.is_binary);
2521 }
2522
2523 #[test]
2524 fn test_classify_file_info_does_not_label_binary_json_garbage_as_json() {
2525 let classification =
2526 classify_file_info(Path::new("broken.json"), &[0xff, 0x00, 0x01, 0x02]);
2527
2528 assert_eq!(classification.mime_type, "application/octet-stream");
2529 assert_eq!(classification.file_type, "data");
2530 assert!(classification.is_binary);
2531 assert!(!classification.is_text);
2532 }
2533
2534 #[test]
2535 fn test_classify_file_info_treats_valid_utf16_json_with_bom_as_text() {
2536 let classification = classify_file_info(
2537 Path::new("utf16.json"),
2538 &[
2539 0xFF, 0xFE, 0x5B, 0x00, 0x22, 0x00, 0xE9, 0x00, 0x22, 0x00, 0x5D, 0x00,
2540 ],
2541 );
2542
2543 assert!(!classification.is_binary);
2544 assert!(classification.is_text);
2545 assert_eq!(classification.mime_type, "application/json");
2546 assert_eq!(classification.file_type, "JSON text data");
2547 }
2548
2549 #[test]
2550 fn test_classify_file_info_treats_valid_utf16be_json_without_bom_as_text() {
2551 let classification = classify_file_info(
2552 Path::new("utf16be.json"),
2553 &[0x00, 0x5B, 0x00, 0x22, 0x00, 0xE9, 0x00, 0x22, 0x00, 0x5D],
2554 );
2555
2556 assert!(!classification.is_binary);
2557 assert!(classification.is_text);
2558 assert_eq!(classification.mime_type, "application/json");
2559 assert_eq!(classification.file_type, "JSON text data");
2560 }
2561
2562 #[test]
2563 fn test_classify_file_info_treats_small_valid_utf16be_json_literal_as_text() {
2564 let classification =
2565 classify_file_info(Path::new("utf16be-literal.json"), &[0x00, 0x5B, 0x00, 0x5D]);
2566
2567 assert!(!classification.is_binary);
2568 assert!(classification.is_text);
2569 assert_eq!(classification.mime_type, "application/json");
2570 assert_eq!(classification.file_type, "JSON text data");
2571 }
2572
2573 #[test]
2574 fn test_extract_text_for_detection_decodes_utf16be_text_with_corrupted_bom_prefix() {
2575 let mut bytes = super::CORRUPTED_UTF16_BOM_PREFIX.to_vec();
2576 for code_unit in
2577 "Licensed to the Apache Software Foundation\nApache License, Version 2.0".encode_utf16()
2578 {
2579 bytes.extend_from_slice(&code_unit.to_be_bytes());
2580 }
2581
2582 let (text, kind) = extract_text_for_detection(Path::new("notice.ftl"), &bytes);
2583
2584 assert_eq!(kind, ExtractedTextKind::Decoded);
2585 assert!(text.contains("Apache Software Foundation"), "{text}");
2586 assert!(text.contains("Apache License, Version 2.0"), "{text}");
2587 }
2588
2589 #[test]
2590 fn test_classify_file_info_treats_small_valid_json_literals_as_text() {
2591 let classification = classify_file_info(Path::new("true.json"), b"true");
2592
2593 assert!(!classification.is_binary);
2594 assert!(classification.is_text);
2595 assert_eq!(classification.mime_type, "application/json");
2596 assert_eq!(classification.file_type, "JSON text data");
2597 }
2598
2599 #[test]
2600 fn test_classify_file_info_treats_json_wrapped_invalid_utf8_sequences_as_text() {
2601 let classification = classify_file_info(
2602 Path::new("wrapped.json"),
2603 &[0x5B, 0x22, 0xE6, 0x97, 0xA5, 0xD1, 0x88, 0xFA, 0x22, 0x5D],
2604 );
2605
2606 assert!(!classification.is_binary);
2607 assert!(classification.is_text);
2608 assert_eq!(classification.mime_type, "text/plain");
2609 assert_eq!(classification.file_type, "text, with no line terminators");
2610 }
2611
2612 #[test]
2613 fn test_classify_file_info_keeps_lone_ff_json_byte_binary() {
2614 let classification =
2615 classify_file_info(Path::new("lone-ff.json"), &[0x5B, 0x22, 0xFF, 0x22, 0x5D]);
2616
2617 assert!(classification.is_binary);
2618 assert!(!classification.is_text);
2619 assert_eq!(classification.mime_type, "application/octet-stream");
2620 assert_eq!(classification.file_type, "data");
2621 }
2622
2623 #[test]
2624 fn test_classify_file_info_keeps_nul_heavy_crash_json_binary() {
2625 let classification = classify_file_info(
2626 Path::new("crash.json"),
2627 &[
2628 0xFE, 0x90, 0x00, 0x00, 0x00, 0x93, 0x5B, 0x5B, 0x32, 0x38, 0x36,
2629 ],
2630 );
2631
2632 assert!(classification.is_binary);
2633 assert!(!classification.is_text);
2634 assert_eq!(classification.mime_type, "application/octet-stream");
2635 }
2636
2637 #[test]
2638 fn test_classify_file_info_treats_dockerfile_as_source() {
2639 let classification = classify_file_info(Path::new("Dockerfile"), b"FROM scratch\n");
2640
2641 assert_eq!(
2642 classification.programming_language.as_deref(),
2643 Some("Dockerfile")
2644 );
2645 assert!(classification.is_source);
2646 assert!(!classification.is_script);
2647 assert_eq!(
2648 classification.file_type,
2649 "Dockerfile source, UTF-8 Unicode text"
2650 );
2651 }
2652
2653 #[test]
2654 fn test_classify_file_info_treats_makefile_as_text_not_source() {
2655 let classification = classify_file_info(Path::new("Makefile"), b"all:\n\techo hi\n");
2656
2657 assert_eq!(classification.programming_language, None);
2658 assert!(classification.is_text);
2659 assert!(!classification.is_source);
2660 assert!(!classification.is_script);
2661 assert_eq!(classification.file_type, "UTF-8 Unicode text");
2662 }
2663
2664 #[test]
2665 fn test_classify_file_info_marks_supported_package_archives() {
2666 let zip_bytes = b"PK\x03\x04\x14\x00\x00\x00";
2667
2668 let egg = classify_file_info(Path::new("demo.egg"), zip_bytes);
2669 let nupkg = classify_file_info(Path::new("demo.nupkg"), zip_bytes);
2670
2671 assert!(egg.is_archive);
2672 assert_eq!(egg.mime_type, "application/zip");
2673 assert_eq!(egg.file_type, "Zip archive data");
2674 assert!(nupkg.is_archive);
2675 assert_eq!(nupkg.mime_type, "application/zip");
2676 assert_eq!(nupkg.file_type, "Zip archive data");
2677 }
2678
2679 #[test]
2680 fn test_classify_file_info_marks_png_as_binary_media() {
2681 let png_bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR";
2682
2683 let classification = classify_file_info(Path::new("logo.png"), png_bytes);
2684
2685 assert_eq!(classification.mime_type, "image/png");
2686 assert_eq!(classification.file_type, "PNG image data");
2687 assert!(classification.is_binary);
2688 assert!(!classification.is_text);
2689 assert!(classification.is_media);
2690 assert!(!classification.is_archive);
2691 assert!(!classification.is_source);
2692 }
2693
2694 #[test]
2695 fn test_classify_file_info_marks_pdf_as_binary_document() {
2696 let pdf_bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\n";
2697
2698 let classification = classify_file_info(Path::new("report.pdf"), pdf_bytes);
2699
2700 assert_eq!(classification.mime_type, "application/pdf");
2701 assert_eq!(classification.file_type, "PDF document");
2702 assert!(classification.is_binary);
2703 assert!(!classification.is_text);
2704 assert!(!classification.is_archive);
2705 assert!(!classification.is_media);
2706 }
2707
2708 #[test]
2709 fn test_classify_file_info_marks_binary_blobs_as_binary() {
2710 let classification =
2711 classify_file_info(Path::new("blob.bin"), &[0, 159, 146, 150, 0, 1, 2, 3, 4, 5]);
2712
2713 assert!(classification.is_binary);
2714 assert!(!classification.is_text);
2715 assert!(!classification.is_source);
2716 assert_eq!(classification.programming_language, None);
2717 }
2718
2719 #[test]
2720 fn test_classify_file_info_treats_yaml_as_text_not_source() {
2721 let classification = classify_file_info(Path::new("config.yaml"), b"key: value\n");
2722
2723 assert_eq!(classification.programming_language, None);
2724 assert!(classification.is_text);
2725 assert!(!classification.is_source);
2726 assert_eq!(classification.file_type, "YAML text data");
2727 }
2728
2729 #[test]
2730 fn test_classify_file_info_classifies_common_build_manifests() {
2731 let gradle = classify_file_info(Path::new("build.gradle"), b"plugins { id 'java' }\n");
2732 let flake = classify_file_info(Path::new("flake.nix"), b"{ inputs, ... }: {}\n");
2733 let cmake = classify_file_info(
2734 Path::new("toolchain.cmake"),
2735 b"set(CMAKE_CXX_STANDARD 20)\n",
2736 );
2737 let gitmodules = classify_file_info(
2738 Path::new(".gitmodules"),
2739 b"[submodule \"demo\"]\n\tpath = vendor/demo\n",
2740 );
2741
2742 assert_eq!(gradle.programming_language.as_deref(), Some("Groovy"));
2743 assert!(gradle.is_source);
2744 assert_eq!(gradle.mime_type, "text/plain");
2745 assert_eq!(gradle.file_type, "Groovy source, UTF-8 Unicode text");
2746
2747 assert_eq!(flake.programming_language.as_deref(), Some("Nix"));
2748 assert!(flake.is_source);
2749 assert_eq!(flake.mime_type, "text/plain");
2750 assert_eq!(flake.file_type, "Nix source, UTF-8 Unicode text");
2751
2752 assert_eq!(cmake.programming_language.as_deref(), Some("CMake"));
2753 assert!(cmake.is_source);
2754 assert_eq!(cmake.file_type, "CMake source, UTF-8 Unicode text");
2755
2756 assert_eq!(gitmodules.programming_language, None);
2757 assert!(gitmodules.is_text);
2758 assert!(!gitmodules.is_source);
2759 assert_eq!(gitmodules.file_type, "Git configuration text");
2760 }
2761
2762 #[test]
2763 fn test_classify_file_info_labels_cpp_headers_and_ipp_separately() {
2764 let header = classify_file_info(
2765 Path::new("include/demo.hpp"),
2766 b"#pragma once\nclass Demo {};\n",
2767 );
2768 let ipp = classify_file_info(
2769 Path::new("include/detail/demo.ipp"),
2770 b"template <class T> void parse() {}\n",
2771 );
2772
2773 assert_eq!(header.programming_language.as_deref(), Some("C++"));
2774 assert!(header.is_source);
2775 assert!(!header.is_script);
2776 assert_eq!(header.file_type, "C++ source, UTF-8 Unicode text");
2777
2778 assert_eq!(ipp.programming_language, None);
2779 assert!(!ipp.is_source);
2780 assert!(!ipp.is_script);
2781 assert_eq!(ipp.file_type, "UTF-8 Unicode text");
2782 }
2783
2784 #[test]
2785 fn test_classify_file_info_preserves_specific_shell_family_labels() {
2786 let bash = classify_file_info(Path::new("bin/run"), b"#!/usr/bin/env bash\necho hi\n");
2787
2788 assert_eq!(bash.programming_language.as_deref(), Some("Bash"));
2789 assert!(bash.is_script);
2790 assert_eq!(bash.file_type, "bash script, UTF-8 Unicode text executable");
2791 }
2792
2793 #[test]
2794 fn test_classify_file_info_marks_jamfile_as_source() {
2795 let jamfile = classify_file_info(Path::new("Jamfile"), b"lib boost_json ;\n");
2796
2797 assert_eq!(jamfile.programming_language.as_deref(), Some("Jamfile"));
2798 assert!(jamfile.is_source);
2799 assert!(!jamfile.is_script);
2800 assert_eq!(jamfile.file_type, "Jamfile source, UTF-8 Unicode text");
2801 }
2802
2803 #[test]
2804 fn test_classify_file_info_labels_javascript_shebang_scripts() {
2805 let classification = classify_file_info(
2806 Path::new("bin/run"),
2807 b"#!/usr/bin/env node\nconsole.log('hello');\n",
2808 );
2809
2810 assert_eq!(
2811 classification.programming_language.as_deref(),
2812 Some("JavaScript")
2813 );
2814 assert!(classification.is_script);
2815 assert_eq!(
2816 classification.file_type,
2817 "javascript script, UTF-8 Unicode text executable"
2818 );
2819 }
2820
2821 #[test]
2822 fn test_classify_file_info_uses_non_utf8_text_labels_for_latin1_scripts() {
2823 let classification = classify_file_info(
2824 Path::new("script.py"),
2825 b"# coding: latin-1\nprint(\"caf\xe9\")\n",
2826 );
2827
2828 assert_eq!(
2829 classification.programming_language.as_deref(),
2830 Some("Python")
2831 );
2832 assert!(classification.is_script);
2833 assert_eq!(classification.file_type, "python script, text executable");
2834 }
2835
2836 #[test]
2837 fn test_classify_file_info_treats_textual_tga_as_media() {
2838 let classification = classify_file_info(Path::new("texture.tga"), b"not really a tga\n");
2839
2840 assert!(classification.is_media);
2841 assert!(classification.is_text);
2842 assert!(!classification.is_binary);
2843 }
2844
2845 #[test]
2846 fn test_classify_file_info_keeps_binaryish_source_extension_out_of_text_path() {
2847 let classification =
2848 classify_file_info(Path::new("main.ts"), &[0x80, 0x81, 0x82, 0x83, 0x84, 0x85]);
2849
2850 assert!(classification.is_binary);
2851 assert!(!classification.is_text);
2852 assert!(!classification.is_source);
2853 assert_eq!(classification.programming_language, None);
2854 }
2855
2856 #[test]
2857 fn test_extract_text_for_detection_skips_unsupported_image_formats() {
2858 let gif_bytes = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;";
2859
2860 let (text, kind) = extract_text_for_detection(Path::new("tiny.gif"), gif_bytes);
2861
2862 assert!(text.is_empty());
2863 assert_eq!(kind, ExtractedTextKind::None);
2864 }
2865
2866 #[test]
2867 fn test_classify_file_info_preserves_language_detection_precedence_matrix() {
2868 let cases = [
2869 (
2870 Path::new("bin/run"),
2871 b"#!/usr/bin/env node\nconsole.log('hello');\n".as_slice(),
2872 Some("JavaScript"),
2873 true,
2874 true,
2875 ),
2876 (
2877 Path::new("Dockerfile"),
2878 b"FROM scratch\n".as_slice(),
2879 Some("Dockerfile"),
2880 true,
2881 false,
2882 ),
2883 (
2884 Path::new("package.json"),
2885 br#"{"name":"demo"}"#.as_slice(),
2886 None,
2887 false,
2888 false,
2889 ),
2890 (
2891 Path::new("config.yaml"),
2892 b"key: value\n".as_slice(),
2893 None,
2894 false,
2895 false,
2896 ),
2897 (
2898 Path::new("Makefile"),
2899 b"all:\n\techo hi\n".as_slice(),
2900 None,
2901 false,
2902 false,
2903 ),
2904 ];
2905
2906 for (path, bytes, expected_language, expected_is_source, expected_is_script) in cases {
2907 let classification = classify_file_info(path, bytes);
2908
2909 assert_eq!(
2910 classification.programming_language.as_deref(),
2911 expected_language,
2912 "unexpected language for {}",
2913 path.display()
2914 );
2915 assert_eq!(
2916 classification.is_source,
2917 expected_is_source,
2918 "unexpected is_source for {}",
2919 path.display()
2920 );
2921 assert_eq!(
2922 classification.is_script,
2923 expected_is_script,
2924 "unexpected is_script for {}",
2925 path.display()
2926 );
2927 }
2928 }
2929}