1mod classify;
18mod encoding;
19mod extract;
20mod format_sniff;
21mod image_metadata;
22mod path;
23mod pdf;
24
25pub use classify::{FileInfoClassification, classify_file_info, detect_mime_type};
26pub use encoding::decode_bytes_to_string;
27pub use extract::{ExtractedTextKind, extract_printable_strings, extract_text_for_detection};
28pub use path::{get_creation_date, is_path_excluded};
29
30pub(crate) use extract::{
31 augment_license_detection_text, extract_text_for_detection_with_diagnostics,
32};
33
34#[cfg(test)]
35mod tests {
36 use image::ImageFormat;
37 use std::path::Path;
38
39 use crate::copyright::detect_copyrights;
40
41 use super::classify::classify_file_info;
42 use super::encoding::{
43 CORRUPTED_UTF16_BOM_PREFIX, NEAR_BINARY_SKIP_DIAGNOSTIC, decode_bytes_to_string,
44 decode_bytes_to_string_with_diagnostic,
45 };
46 use super::extract::{
47 ExtractedTextKind, LARGE_OPAQUE_BINARY_SKIP_BYTES, extract_printable_strings,
48 extract_text_for_detection, extract_text_for_detection_with_diagnostics,
49 windows_metadata_or_empty_result,
50 };
51 use super::image_metadata::{MAX_XMP_PACKET_BYTES, extract_raw_xmp_packet};
52 use super::pdf::{MAX_PDF_TEXT_EXTRACTION_BYTES, normalize_pdf_heading_comparison_text};
53
54 fn png_chunk(chunk_type: &[u8; 4], data: &[u8]) -> Vec<u8> {
55 let mut out = Vec::new();
56 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
57 out.extend_from_slice(chunk_type);
58 out.extend_from_slice(data);
59 out.extend_from_slice(&0u32.to_be_bytes());
60 out
61 }
62
63 fn build_png_with_xmp(xmp: &str) -> Vec<u8> {
64 let mut bytes = Vec::new();
65 bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
66
67 let ihdr = [
68 0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0, ];
76 bytes.extend_from_slice(&png_chunk(b"IHDR", &ihdr));
77
78 let mut itxt = Vec::new();
79 itxt.extend_from_slice(b"XML:com.adobe.xmp");
80 itxt.push(0); itxt.push(0); itxt.push(0); itxt.push(0); itxt.push(0); itxt.extend_from_slice(xmp.as_bytes());
86 bytes.extend_from_slice(&png_chunk(b"iTXt", &itxt));
87
88 bytes.extend_from_slice(&png_chunk(b"IEND", &[]));
89 bytes
90 }
91
92 #[test]
93 fn test_extract_text_for_detection_skips_jar_archives() {
94 let path = Path::new(
95 "testdata/license-golden/datadriven/lic1/do-not_detect-licenses-in-archive.jar",
96 );
97 let bytes = std::fs::read(path).expect("failed to read jar fixture");
98
99 let (text, kind) = extract_text_for_detection(path, &bytes);
100
101 assert!(text.is_empty());
102 assert_eq!(kind, ExtractedTextKind::None);
103 }
104
105 #[test]
106 fn test_extract_text_for_detection_reads_pdf_fixture_text() {
107 let path = Path::new("testdata/license-golden/datadriven/lic2/bsd-new_156.pdf");
108 let bytes = std::fs::read(path).expect("failed to read pdf fixture");
109
110 let (text, kind) = extract_text_for_detection(path, &bytes);
111
112 assert_eq!(kind, ExtractedTextKind::Pdf);
113 assert!(text.contains("Redistribution and use in source and binary forms"));
114 }
115
116 #[test]
117 fn test_extract_text_for_detection_prefers_first_pdf_page_before_full_document() {
118 let path =
119 Path::new("testdata/license-golden/datadriven/lic4/should_detect_something_5.pdf");
120 let bytes = std::fs::read(path).expect("failed to read pdf fixture");
121
122 let (text, kind) = extract_text_for_detection(path, &bytes);
123
124 assert_eq!(kind, ExtractedTextKind::Pdf);
125 assert!(text.contains("SUN INDUSTRY STANDARDS SOURCE LICENSE"));
126 assert!(!text.contains("DISCLAIMER OF WARRANTY"));
127 }
128
129 #[test]
130 fn test_extract_text_for_detection_does_not_duplicate_pdf_heading_prefix() {
131 let path =
132 Path::new("testdata/license-golden/datadriven/lic4/should_detect_something_5.pdf");
133 let bytes = std::fs::read(path).expect("failed to read pdf fixture");
134
135 let (text, kind) = extract_text_for_detection(path, &bytes);
136
137 assert_eq!(kind, ExtractedTextKind::Pdf);
138
139 let normalized = normalize_pdf_heading_comparison_text(&text);
140 let heading =
141 normalize_pdf_heading_comparison_text("SUN INDUSTRY STANDARDS SOURCE LICENSE");
142 assert_eq!(normalized.matches(&heading).count(), 1);
143 }
144
145 #[test]
146 fn test_extract_text_for_detection_reads_pdf_fixture_without_pdf_extension() {
147 let path = Path::new("testdata/license-golden/datadriven/lic2/bsd-new_156.pdf");
148 let bytes = std::fs::read(path).expect("failed to read pdf fixture");
149
150 let (text, kind) = extract_text_for_detection(Path::new("renamed.bin"), &bytes);
151
152 assert_eq!(kind, ExtractedTextKind::Pdf);
153 assert!(text.contains("Redistribution and use in source and binary forms"));
154 }
155
156 #[test]
157 fn test_extract_text_for_detection_skips_oversized_pdf_payload() {
158 let mut bytes = b"%PDF-1.7\n".to_vec();
159 bytes.resize(MAX_PDF_TEXT_EXTRACTION_BYTES + 1, b'0');
160
161 let (text, kind, scan_error) =
162 extract_text_for_detection_with_diagnostics(Path::new("oversized.pdf"), &bytes);
163
164 assert!(text.is_empty());
165 assert_eq!(kind, ExtractedTextKind::None);
166 assert!(
167 scan_error
168 .as_deref()
169 .is_some_and(|message| message.contains("PDF text extraction skipped"))
170 );
171 }
172
173 #[test]
174 fn test_extract_text_for_detection_reports_terminal_pdf_failure() {
175 let malformed = b"%PDF-1.7\nthis is not a valid pdf object graph\n";
176
177 let (text, kind, scan_error) =
178 extract_text_for_detection_with_diagnostics(Path::new("broken.pdf"), malformed);
179
180 assert!(text.is_empty());
181 assert_eq!(kind, ExtractedTextKind::None);
182 let scan_error = scan_error.expect("terminal pdf failure should be surfaced");
183 assert!(scan_error.contains("PDF text extraction failed after"));
184 }
185
186 #[test]
187 fn test_extract_text_for_detection_skips_large_opaque_binary_blobs() {
188 let bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES + 8];
189
190 let (text, kind) = extract_text_for_detection(Path::new("model.bin"), &bytes);
191
192 assert!(text.is_empty());
193 assert_eq!(kind, ExtractedTextKind::None);
194 }
195
196 #[test]
197 fn test_extract_text_for_detection_keeps_large_binaries_with_promising_strings() {
198 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES + 8];
199 let text = b"Copyright 2026 Example Project!!!";
200 bytes[..text.len()].copy_from_slice(text);
201 let second_offset = LARGE_OPAQUE_BINARY_SKIP_BYTES / 2;
202 bytes[second_offset..second_offset + text.len()].copy_from_slice(text);
203
204 let (text, kind) = extract_text_for_detection(Path::new("weights.bin"), &bytes);
205
206 assert_ne!(kind, ExtractedTextKind::None);
207 assert!(text.contains("Copyright 2026 Example Project"));
208 }
209
210 #[test]
211 fn test_extract_text_for_detection_skips_large_binary_with_unstructured_runs() {
212 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES + 8];
213 let noise = b"(c) $1234567890ABCDEF[]{}--==++";
214 bytes[..noise.len()].copy_from_slice(noise);
215 let second_offset = LARGE_OPAQUE_BINARY_SKIP_BYTES / 2;
216 bytes[second_offset..second_offset + noise.len()].copy_from_slice(noise);
217
218 let (text, kind) = extract_text_for_detection(Path::new("tensor.bin"), &bytes);
219
220 assert!(text.is_empty());
221 assert_eq!(kind, ExtractedTextKind::None);
222 }
223
224 #[test]
225 fn test_extract_text_for_detection_uses_windows_executable_metadata() {
226 let path = Path::new("testdata/compiled-binary-golden/win_pe/libiconv2.dll");
227 let bytes = std::fs::read(path).expect("read PE fixture");
228
229 let (text, kind) = extract_text_for_detection(path, &bytes);
230
231 assert_eq!(kind, ExtractedTextKind::BinaryStrings);
232 assert!(text.contains("License: This program is free software"));
233 assert!(text.contains("LegalCopyright:"));
234 }
235
236 #[test]
237 fn test_extract_text_for_detection_keeps_windows_metadata_for_large_pe_without_sampled_signal()
238 {
239 let path = Path::new("testdata/compiled-binary-golden/win_pe/libiconv2.dll");
240 let mut bytes = std::fs::read(path).expect("read PE fixture");
241 bytes.resize(LARGE_OPAQUE_BINARY_SKIP_BYTES + 8, 0);
242
243 let (text, kind) = extract_text_for_detection(path, &bytes);
244
245 assert_ne!(kind, ExtractedTextKind::None);
246 assert!(!text.trim().is_empty());
247 }
248
249 #[test]
250 fn test_windows_metadata_or_empty_result_preserves_metadata() {
251 let (text, kind, scan_error) =
252 windows_metadata_or_empty_result(Some("LegalCopyright: Example Corp".to_string()));
253
254 assert_eq!(kind, ExtractedTextKind::WindowsExecutableMetadata);
255 assert_eq!(text, "LegalCopyright: Example Corp");
256 assert!(scan_error.is_none());
257 }
258
259 #[test]
260 fn test_extract_text_for_detection_keeps_image_author_separate_from_title_and_description() {
261 let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:creator>Chinmay Garde</dc:creator><dc:title>Bay Bridge At Night</dc:title><dc:description>Embarcadero in the evening on Delta 3200</dc:description></rdf:Description></rdf:RDF></x:xmpmeta>"#;
262 let bytes = build_png_with_xmp(xmp);
263
264 let (text, kind) = extract_text_for_detection(Path::new("fixture.png"), &bytes);
265
266 assert_eq!(kind, ExtractedTextKind::ImageMetadata);
267 assert!(text.contains("Author: Chinmay Garde"), "text: {text:?}");
268 assert!(
269 text.contains("Title: Bay Bridge At Night"),
270 "text: {text:?}"
271 );
272 assert!(
273 text.contains("Description: Embarcadero in the evening on Delta 3200"),
274 "text: {text:?}"
275 );
276
277 let (_copyrights, _holders, authors) = detect_copyrights(&text, None);
278 assert_eq!(
279 authors
280 .iter()
281 .map(|a| a.author.as_str())
282 .collect::<Vec<_>>(),
283 vec!["Chinmay Garde"],
284 "authors: {authors:?}; text: {text:?}"
285 );
286 }
287
288 #[test]
289 fn test_extract_text_for_detection_skips_large_binary_with_single_isolated_string_run() {
290 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES + 8];
291 let text = b"Copyright 2026 Example Project!!!";
292 bytes[..text.len()].copy_from_slice(text);
293
294 let (text, kind) = extract_text_for_detection(Path::new("opaque.bin"), &bytes);
295
296 assert!(text.is_empty());
297 assert_eq!(kind, ExtractedTextKind::None);
298 }
299
300 #[test]
301 fn test_extract_text_for_detection_keeps_large_binary_with_single_contact_rich_window() {
302 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES + 8];
303 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/";
304 bytes[..text.len()].copy_from_slice(text);
305
306 let (text, kind) = extract_text_for_detection(Path::new("rootfs.bin"), &bytes);
307
308 assert_ne!(kind, ExtractedTextKind::None);
309 assert!(text.contains("asn@redhat.com"));
310 assert!(text.contains("https://publicsuffix.org/"));
311 }
312
313 #[test]
314 fn test_extract_text_for_detection_keeps_large_macho_with_off_window_legal_markers() {
315 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES * 2];
316 bytes[..4].copy_from_slice(&[0xCF, 0xFA, 0xED, 0xFE]);
317 let apache_notice = b"// Licensed under the Apache License, Version 2.0 (the \"License\");\n// http://www.apache.org/licenses/LICENSE-2.0\n// SPDX-License-Identifier: Apache-2.0\n";
318 let insert_offset = 200 * 1024;
319 bytes[insert_offset..insert_offset + apache_notice.len()].copy_from_slice(apache_notice);
320
321 let (text, kind) = extract_text_for_detection(Path::new("node"), &bytes);
322
323 assert_eq!(kind, ExtractedTextKind::BinaryStrings);
324 assert!(text.contains("Apache License, Version 2.0"), "{text}");
325 assert!(
326 text.contains("SPDX-License-Identifier: Apache-2.0"),
327 "{text}"
328 );
329 }
330
331 #[test]
332 fn test_extract_text_for_detection_keeps_large_macho_with_unicode_notice_markers() {
333 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES * 2];
334 bytes[..4].copy_from_slice(&[0xCF, 0xFA, 0xED, 0xFE]);
335 let unicode_notice = b"Copyright (c) 1991-2024 Unicode, Inc.\nFor terms of use, see http://www.unicode.org/copyright.html\n";
336 let insert_offset = 700 * 1024;
337 bytes[insert_offset..insert_offset + unicode_notice.len()].copy_from_slice(unicode_notice);
338
339 let (text, kind) = extract_text_for_detection(Path::new("node"), &bytes);
340
341 assert_eq!(kind, ExtractedTextKind::BinaryStrings);
342 assert!(text.contains("Unicode, Inc."), "{text}");
343 assert!(text.contains("unicode.org/copyright.html"), "{text}");
344 }
345
346 #[test]
347 fn test_extract_text_for_detection_does_not_reopen_single_window_legal_noise_for_non_macho() {
348 let mut bytes = vec![0_u8; LARGE_OPAQUE_BINARY_SKIP_BYTES * 2];
349 let apache_notice = b"// Licensed under the Apache License, Version 2.0 (the \"License\");\n// http://www.apache.org/licenses/LICENSE-2.0\n// SPDX-License-Identifier: Apache-2.0\n";
350 let insert_offset = 200 * 1024;
351 bytes[insert_offset..insert_offset + apache_notice.len()].copy_from_slice(apache_notice);
352
353 let (text, kind) = extract_text_for_detection(Path::new("model.bin"), &bytes);
354
355 assert!(text.is_empty());
356 assert_eq!(kind, ExtractedTextKind::None);
357 }
358
359 #[test]
360 fn test_extract_text_for_detection_avoids_latin1_decode_for_binary_blob_noise() {
361 let bytes = vec![
362 0x28, 0x63, 0x29, 0x20, 0x4b, 0x30, 0x0e, 0x71, 0x86, 0x20, 0x62, 0x24, 0x4c,
363 ];
364
365 let (text, kind) = extract_text_for_detection(Path::new("fixture.blb"), &bytes);
366
367 assert_eq!(kind, ExtractedTextKind::BinaryStrings);
368 assert_eq!(text, "(c) K0\n b$L");
369 }
370
371 #[test]
372 fn test_extract_raw_xmp_packet_rejects_oversized_png_itxt_payload() {
373 let xmp = "A".repeat(MAX_XMP_PACKET_BYTES + 1);
374 let bytes = build_png_with_xmp(&xmp);
375
376 assert!(extract_raw_xmp_packet(&bytes, ImageFormat::Png).is_none());
377 }
378
379 #[test]
380 fn test_extract_text_for_detection_skips_zip_like_archives() {
381 let zip_bytes = b"PK\x03\x04\x14\x00\x00\x00\x08\x00artifact";
382
383 let (whl_text, whl_kind) = extract_text_for_detection(Path::new("demo.whl"), zip_bytes);
384 let (crate_text, crate_kind) =
385 extract_text_for_detection(Path::new("demo.crate"), zip_bytes);
386
387 assert!(whl_text.is_empty());
388 assert_eq!(whl_kind, ExtractedTextKind::None);
389 assert!(crate_text.is_empty());
390 assert_eq!(crate_kind, ExtractedTextKind::None);
391 }
392
393 #[test]
394 fn test_extract_text_for_detection_keeps_binary_strings_for_lib_fixtures() {
395 let path =
396 Path::new("testdata/copyright-golden/copyrights/copyright_php_lib-php_embed_lib.lib");
397 let bytes = std::fs::read(path).expect("failed to read lib fixture");
398
399 let (text, kind) = extract_text_for_detection(path, &bytes);
400
401 assert_ne!(kind, ExtractedTextKind::None);
402 assert!(text.contains("Copyright nexB and others (c) 2012"));
403 }
404
405 #[test]
406 fn test_extract_text_for_detection_reads_font_metadata() {
407 let path = Path::new("testdata/font-fixtures/Lato-Bold.ttf");
408 let bytes = std::fs::read(path).expect("failed to read font fixture");
409
410 let (text, kind) = extract_text_for_detection(path, &bytes);
411
412 assert_eq!(kind, ExtractedTextKind::FontMetadata);
413 assert!(text.contains("License Description:"), "{text}");
414 assert!(
415 text.contains("Open Font License") || text.contains("OFL"),
416 "{text}"
417 );
418 assert!(text.contains("Lato"), "{text}");
419 }
420
421 #[test]
422 fn test_extract_printable_strings_scales_cap_for_medium_binary_files() {
423 let bytes = b"abcd\0".repeat(525_000);
424
425 let text = extract_printable_strings(&bytes);
426
427 assert!(
428 text.len() > 2_000_000,
429 "unexpected truncation at {}",
430 text.len()
431 );
432 assert!(text.ends_with("abcd"));
433 }
434
435 #[test]
436 fn test_extract_text_for_detection_decodes_svg_fixture_text() {
437 let path = Path::new(
438 "testdata/license-golden/datadriven/external/fossology-tests/Public-domain/biohazard.svg",
439 );
440 let bytes = std::fs::read(path).expect("failed to read svg fixture");
441
442 let (text, kind) = extract_text_for_detection(path, &bytes);
443
444 assert_eq!(kind, ExtractedTextKind::Decoded);
445 assert!(text.contains("creativecommons.org/licenses/publicdomain"));
446 }
447
448 #[test]
449 fn test_extract_text_for_detection_preserves_blank_comment_lines_in_utf8_source() {
450 let path = Path::new("testdata/plugin_email_url/files/IMarkerActionFilter.java");
451 let bytes = std::fs::read(path).expect("failed to read java fixture");
452
453 let (text, kind) = extract_text_for_detection(path, &bytes);
454
455 assert_eq!(kind, ExtractedTextKind::Decoded);
456 let lines: Vec<_> = text.lines().collect();
457 assert_eq!(lines.get(2).copied(), Some(" *"));
458 assert_eq!(
459 lines.get(3).copied(),
460 Some(" *https://github.com/rpm-software-management")
461 );
462 assert_eq!(lines.get(5).copied(), Some("https://gitlab.com/Conan_Kudo"));
463 }
464
465 #[test]
466 fn test_extract_text_for_detection_decodes_rtf_fixture_text() {
467 let path = Path::new(
468 "testdata/license-golden/datadriven/external/fossology-tests/LGPL/License.rtf",
469 );
470 let bytes = std::fs::read(path).expect("failed to read rtf fixture");
471
472 let (text, kind) = extract_text_for_detection(path, &bytes);
473
474 assert_eq!(kind, ExtractedTextKind::Decoded);
475 assert!(text.contains("GNU Lesser General Public"));
476 assert!(text.contains("version"));
477 assert!(text.contains("2.1 of the License"));
478 }
479
480 #[test]
481 fn test_classify_file_info_marks_empty_files_as_text_not_source() {
482 let classification = classify_file_info(Path::new("test.txt"), b"");
483
484 assert_eq!(classification.mime_type, "inode/x-empty");
485 assert_eq!(classification.file_type, "empty");
486 assert!(!classification.is_binary);
487 assert!(classification.is_text);
488 assert!(!classification.is_source);
489 assert_eq!(classification.programming_language, None);
490 }
491
492 #[test]
493 fn test_classify_file_info_keeps_json_out_of_programming_language() {
494 let classification = classify_file_info(Path::new("package.json"), br#"{"name":"demo"}"#);
495
496 assert_eq!(classification.mime_type, "application/json");
497 assert_eq!(classification.file_type, "JSON text data");
498 assert!(classification.is_text);
499 assert!(!classification.is_source);
500 assert_eq!(classification.programming_language, None);
501 }
502
503 #[test]
504 fn test_classify_file_info_does_not_label_invalid_json_text_as_json() {
505 let classification =
506 classify_file_info(Path::new("broken.json"), b"{ definitely not json\n");
507
508 assert_eq!(classification.mime_type, "text/plain");
509 assert_eq!(classification.file_type, "UTF-8 Unicode text");
510 assert!(classification.is_text);
511 assert!(!classification.is_binary);
512 }
513
514 #[test]
515 fn test_classify_file_info_does_not_label_binary_json_garbage_as_json() {
516 let classification =
517 classify_file_info(Path::new("broken.json"), &[0xff, 0x00, 0x01, 0x02]);
518
519 assert_eq!(classification.mime_type, "application/octet-stream");
520 assert_eq!(classification.file_type, "data");
521 assert!(classification.is_binary);
522 assert!(!classification.is_text);
523 }
524
525 #[test]
526 fn test_classify_file_info_treats_valid_utf16_json_with_bom_as_text() {
527 let classification = classify_file_info(
528 Path::new("utf16.json"),
529 &[
530 0xFF, 0xFE, 0x5B, 0x00, 0x22, 0x00, 0xE9, 0x00, 0x22, 0x00, 0x5D, 0x00,
531 ],
532 );
533
534 assert!(!classification.is_binary);
535 assert!(classification.is_text);
536 assert_eq!(classification.mime_type, "application/json");
537 assert_eq!(classification.file_type, "JSON text data");
538 }
539
540 #[test]
541 fn test_classify_file_info_treats_valid_utf16be_json_without_bom_as_text() {
542 let classification = classify_file_info(
543 Path::new("utf16be.json"),
544 &[0x00, 0x5B, 0x00, 0x22, 0x00, 0xE9, 0x00, 0x22, 0x00, 0x5D],
545 );
546
547 assert!(!classification.is_binary);
548 assert!(classification.is_text);
549 assert_eq!(classification.mime_type, "application/json");
550 assert_eq!(classification.file_type, "JSON text data");
551 }
552
553 #[test]
554 fn test_classify_file_info_treats_small_valid_utf16be_json_literal_as_text() {
555 let classification =
556 classify_file_info(Path::new("utf16be-literal.json"), &[0x00, 0x5B, 0x00, 0x5D]);
557
558 assert!(!classification.is_binary);
559 assert!(classification.is_text);
560 assert_eq!(classification.mime_type, "application/json");
561 assert_eq!(classification.file_type, "JSON text data");
562 }
563
564 #[test]
565 fn test_extract_text_for_detection_decodes_utf16be_text_with_corrupted_bom_prefix() {
566 let mut bytes = CORRUPTED_UTF16_BOM_PREFIX.to_vec();
567 for code_unit in
568 "Licensed to the Apache Software Foundation\nApache License, Version 2.0".encode_utf16()
569 {
570 bytes.extend_from_slice(&code_unit.to_be_bytes());
571 }
572
573 let (text, kind) = extract_text_for_detection(Path::new("notice.ftl"), &bytes);
574
575 assert_eq!(kind, ExtractedTextKind::Decoded);
576 assert!(text.contains("Apache Software Foundation"), "{text}");
577 assert!(text.contains("Apache License, Version 2.0"), "{text}");
578 }
579
580 #[test]
581 fn test_classify_file_info_treats_small_valid_json_literals_as_text() {
582 let classification = classify_file_info(Path::new("true.json"), b"true");
583
584 assert!(!classification.is_binary);
585 assert!(classification.is_text);
586 assert_eq!(classification.mime_type, "application/json");
587 assert_eq!(classification.file_type, "JSON text data");
588 }
589
590 #[test]
591 fn test_classify_file_info_treats_json_wrapped_invalid_utf8_sequences_as_text() {
592 let classification = classify_file_info(
593 Path::new("wrapped.json"),
594 &[0x5B, 0x22, 0xE6, 0x97, 0xA5, 0xD1, 0x88, 0xFA, 0x22, 0x5D],
595 );
596
597 assert!(!classification.is_binary);
598 assert!(classification.is_text);
599 assert_eq!(classification.mime_type, "text/plain");
600 assert_eq!(classification.file_type, "text, with no line terminators");
601 }
602
603 #[test]
604 fn test_classify_file_info_keeps_lone_ff_json_byte_binary() {
605 let classification =
606 classify_file_info(Path::new("lone-ff.json"), &[0x5B, 0x22, 0xFF, 0x22, 0x5D]);
607
608 assert!(classification.is_binary);
609 assert!(!classification.is_text);
610 assert_eq!(classification.mime_type, "application/octet-stream");
611 assert_eq!(classification.file_type, "data");
612 }
613
614 #[test]
615 fn test_classify_file_info_keeps_nul_heavy_crash_json_binary() {
616 let classification = classify_file_info(
617 Path::new("crash.json"),
618 &[
619 0xFE, 0x90, 0x00, 0x00, 0x00, 0x93, 0x5B, 0x5B, 0x32, 0x38, 0x36,
620 ],
621 );
622
623 assert!(classification.is_binary);
624 assert!(!classification.is_text);
625 assert_eq!(classification.mime_type, "application/octet-stream");
626 }
627
628 #[test]
629 fn test_classify_file_info_treats_dockerfile_as_source() {
630 let classification = classify_file_info(Path::new("Dockerfile"), b"FROM scratch\n");
631
632 assert_eq!(
633 classification.programming_language.as_deref(),
634 Some("Dockerfile")
635 );
636 assert!(classification.is_source);
637 assert!(!classification.is_script);
638 assert_eq!(
639 classification.file_type,
640 "Dockerfile source, UTF-8 Unicode text"
641 );
642 }
643
644 #[test]
645 fn test_classify_file_info_treats_makefile_as_text_not_source() {
646 let classification = classify_file_info(Path::new("Makefile"), b"all:\n\techo hi\n");
647
648 assert_eq!(classification.programming_language, None);
649 assert!(classification.is_text);
650 assert!(!classification.is_source);
651 assert!(!classification.is_script);
652 assert_eq!(classification.file_type, "UTF-8 Unicode text");
653 }
654
655 #[test]
656 fn test_classify_file_info_marks_supported_package_archives() {
657 let zip_bytes = b"PK\x03\x04\x14\x00\x00\x00";
658
659 let egg = classify_file_info(Path::new("demo.egg"), zip_bytes);
660 let nupkg = classify_file_info(Path::new("demo.nupkg"), zip_bytes);
661
662 assert!(egg.is_archive);
663 assert_eq!(egg.mime_type, "application/zip");
664 assert_eq!(egg.file_type, "Zip archive data");
665 assert!(nupkg.is_archive);
666 assert_eq!(nupkg.mime_type, "application/zip");
667 assert_eq!(nupkg.file_type, "Zip archive data");
668 }
669
670 #[test]
671 fn test_classify_file_info_marks_png_as_binary_media() {
672 let png_bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR";
673
674 let classification = classify_file_info(Path::new("logo.png"), png_bytes);
675
676 assert_eq!(classification.mime_type, "image/png");
677 assert_eq!(classification.file_type, "PNG image data");
678 assert!(classification.is_binary);
679 assert!(!classification.is_text);
680 assert!(classification.is_media);
681 assert!(!classification.is_archive);
682 assert!(!classification.is_source);
683 }
684
685 #[test]
686 fn test_classify_file_info_marks_pdf_as_binary_document() {
687 let pdf_bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\n";
688
689 let classification = classify_file_info(Path::new("report.pdf"), pdf_bytes);
690
691 assert_eq!(classification.mime_type, "application/pdf");
692 assert_eq!(classification.file_type, "PDF document");
693 assert!(classification.is_binary);
694 assert!(!classification.is_text);
695 assert!(!classification.is_archive);
696 assert!(!classification.is_media);
697 }
698
699 #[test]
700 fn test_classify_file_info_marks_binary_blobs_as_binary() {
701 let classification =
702 classify_file_info(Path::new("blob.bin"), &[0, 159, 146, 150, 0, 1, 2, 3, 4, 5]);
703
704 assert!(classification.is_binary);
705 assert!(!classification.is_text);
706 assert!(!classification.is_source);
707 assert_eq!(classification.programming_language, None);
708 }
709
710 #[test]
711 fn test_classify_file_info_treats_yaml_as_text_not_source() {
712 let classification = classify_file_info(Path::new("config.yaml"), b"key: value\n");
713
714 assert_eq!(classification.programming_language, None);
715 assert!(classification.is_text);
716 assert!(!classification.is_source);
717 assert_eq!(classification.file_type, "YAML text data");
718 }
719
720 #[test]
721 fn test_classify_file_info_classifies_common_build_manifests() {
722 let gradle = classify_file_info(Path::new("build.gradle"), b"plugins { id 'java' }\n");
723 let flake = classify_file_info(Path::new("flake.nix"), b"{ inputs, ... }: {}\n");
724 let cmake = classify_file_info(
725 Path::new("toolchain.cmake"),
726 b"set(CMAKE_CXX_STANDARD 20)\n",
727 );
728 let gitmodules = classify_file_info(
729 Path::new(".gitmodules"),
730 b"[submodule \"demo\"]\n\tpath = vendor/demo\n",
731 );
732
733 assert_eq!(gradle.programming_language.as_deref(), Some("Groovy"));
734 assert!(gradle.is_source);
735 assert_eq!(gradle.mime_type, "text/plain");
736 assert_eq!(gradle.file_type, "Groovy source, UTF-8 Unicode text");
737
738 assert_eq!(flake.programming_language.as_deref(), Some("Nix"));
739 assert!(flake.is_source);
740 assert_eq!(flake.mime_type, "text/plain");
741 assert_eq!(flake.file_type, "Nix source, UTF-8 Unicode text");
742
743 assert_eq!(cmake.programming_language.as_deref(), Some("CMake"));
744 assert!(cmake.is_source);
745 assert_eq!(cmake.file_type, "CMake source, UTF-8 Unicode text");
746
747 assert_eq!(gitmodules.programming_language, None);
748 assert!(gitmodules.is_text);
749 assert!(!gitmodules.is_source);
750 assert_eq!(gitmodules.file_type, "Git configuration text");
751 }
752
753 #[test]
754 fn test_classify_file_info_labels_cpp_headers_and_ipp_separately() {
755 let header = classify_file_info(
756 Path::new("include/demo.hpp"),
757 b"#pragma once\nclass Demo {};\n",
758 );
759 let ipp = classify_file_info(
760 Path::new("include/detail/demo.ipp"),
761 b"template <class T> void parse() {}\n",
762 );
763
764 assert_eq!(header.programming_language.as_deref(), Some("C++"));
765 assert!(header.is_source);
766 assert!(!header.is_script);
767 assert_eq!(header.file_type, "C++ source, UTF-8 Unicode text");
768
769 assert_eq!(ipp.programming_language, None);
770 assert!(!ipp.is_source);
771 assert!(!ipp.is_script);
772 assert_eq!(ipp.file_type, "UTF-8 Unicode text");
773 }
774
775 #[test]
776 fn test_classify_file_info_preserves_specific_shell_family_labels() {
777 let bash = classify_file_info(Path::new("bin/run"), b"#!/usr/bin/env bash\necho hi\n");
778
779 assert_eq!(bash.programming_language.as_deref(), Some("Bash"));
780 assert!(bash.is_script);
781 assert_eq!(bash.file_type, "bash script, UTF-8 Unicode text executable");
782 }
783
784 #[test]
785 fn test_classify_file_info_marks_jamfile_as_source() {
786 let jamfile = classify_file_info(Path::new("Jamfile"), b"lib boost_json ;\n");
787
788 assert_eq!(jamfile.programming_language.as_deref(), Some("Jamfile"));
789 assert!(jamfile.is_source);
790 assert!(!jamfile.is_script);
791 assert_eq!(jamfile.file_type, "Jamfile source, UTF-8 Unicode text");
792 }
793
794 #[test]
795 fn test_classify_file_info_labels_javascript_shebang_scripts() {
796 let classification = classify_file_info(
797 Path::new("bin/run"),
798 b"#!/usr/bin/env node\nconsole.log('hello');\n",
799 );
800
801 assert_eq!(
802 classification.programming_language.as_deref(),
803 Some("JavaScript")
804 );
805 assert!(classification.is_script);
806 assert_eq!(
807 classification.file_type,
808 "javascript script, UTF-8 Unicode text executable"
809 );
810 }
811
812 #[test]
813 fn test_classify_file_info_uses_non_utf8_text_labels_for_latin1_scripts() {
814 let classification = classify_file_info(
815 Path::new("script.py"),
816 b"# coding: latin-1\nprint(\"caf\xe9\")\n",
817 );
818
819 assert_eq!(
820 classification.programming_language.as_deref(),
821 Some("Python")
822 );
823 assert!(classification.is_script);
824 assert_eq!(classification.file_type, "python script, text executable");
825 }
826
827 #[test]
828 fn test_classify_file_info_treats_textual_tga_as_media() {
829 let classification = classify_file_info(Path::new("texture.tga"), b"not really a tga\n");
830
831 assert!(classification.is_media);
832 assert!(classification.is_text);
833 assert!(!classification.is_binary);
834 }
835
836 #[test]
837 fn test_classify_file_info_keeps_binaryish_source_extension_out_of_text_path() {
838 let classification =
839 classify_file_info(Path::new("main.ts"), &[0x80, 0x81, 0x82, 0x83, 0x84, 0x85]);
840
841 assert!(classification.is_binary);
842 assert!(!classification.is_text);
843 assert!(!classification.is_source);
844 assert_eq!(classification.programming_language, None);
845 }
846
847 #[test]
848 fn test_extract_text_for_detection_skips_unsupported_image_formats() {
849 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;";
850
851 let (text, kind) = extract_text_for_detection(Path::new("tiny.gif"), gif_bytes);
852
853 assert!(text.is_empty());
854 assert_eq!(kind, ExtractedTextKind::None);
855 }
856
857 #[test]
858 fn test_classify_file_info_preserves_language_detection_precedence_matrix() {
859 let cases = [
860 (
861 Path::new("bin/run"),
862 b"#!/usr/bin/env node\nconsole.log('hello');\n".as_slice(),
863 Some("JavaScript"),
864 true,
865 true,
866 ),
867 (
868 Path::new("Dockerfile"),
869 b"FROM scratch\n".as_slice(),
870 Some("Dockerfile"),
871 true,
872 false,
873 ),
874 (
875 Path::new("package.json"),
876 br#"{"name":"demo"}"#.as_slice(),
877 None,
878 false,
879 false,
880 ),
881 (
882 Path::new("config.yaml"),
883 b"key: value\n".as_slice(),
884 None,
885 false,
886 false,
887 ),
888 (
889 Path::new("Makefile"),
890 b"all:\n\techo hi\n".as_slice(),
891 None,
892 false,
893 false,
894 ),
895 ];
896
897 for (path, bytes, expected_language, expected_is_source, expected_is_script) in cases {
898 let classification = classify_file_info(path, bytes);
899
900 assert_eq!(
901 classification.programming_language.as_deref(),
902 expected_language,
903 "unexpected language for {}",
904 path.display()
905 );
906 assert_eq!(
907 classification.is_source,
908 expected_is_source,
909 "unexpected is_source for {}",
910 path.display()
911 );
912 assert_eq!(
913 classification.is_script,
914 expected_is_script,
915 "unexpected is_script for {}",
916 path.display()
917 );
918 }
919 }
920
921 fn invalid_utf8_with_control_ratio(printable: usize, control: usize) -> Vec<u8> {
943 let mut bytes = vec![0x80]; bytes.extend(std::iter::repeat_n(b'A', printable));
945 bytes.extend(std::iter::repeat_n(0x01, control));
946 bytes
947 }
948
949 #[test]
950 fn decode_bytes_control_char_threshold_just_under_decodes_as_latin1() {
951 let bytes = invalid_utf8_with_control_ratio(89, 10);
954 assert_eq!(bytes.len(), 100);
955
956 let (decoded, diagnostic) = decode_bytes_to_string_with_diagnostic(&bytes);
957
958 assert!(
959 !decoded.is_empty(),
960 "input at the 10% boundary must still decode (Latin-1 fallback)"
961 );
962 assert!(decoded.contains('A'));
963 assert!(diagnostic.is_none());
964 }
965
966 #[test]
967 fn decode_bytes_control_char_threshold_just_over_returns_empty_with_diagnostic() {
968 let bytes = invalid_utf8_with_control_ratio(88, 11);
971 assert_eq!(bytes.len(), 100);
972
973 let (decoded, diagnostic) = decode_bytes_to_string_with_diagnostic(&bytes);
974
975 assert!(
976 decoded.is_empty(),
977 "input over the 10% control-byte threshold must decode to empty"
978 );
979 assert_eq!(diagnostic.as_deref(), Some(NEAR_BINARY_SKIP_DIAGNOSTIC));
980 assert!(decode_bytes_to_string(&bytes).is_empty());
982 }
983
984 #[test]
985 fn decode_bytes_latin1_high_bytes_produce_mojibake_parity() {
986 let windows_1252 = b"caf\xE9";
990 let decoded = decode_bytes_to_string(windows_1252);
991 assert_eq!(decoded, "caf\u{00E9}");
993
994 let shift_jis = b"\x93\xFA"; let decoded = decode_bytes_to_string(shift_jis);
996 assert_eq!(decoded, "\u{0093}\u{00FA}");
998 assert!(!decoded.contains('日'));
999 }
1000
1001 #[test]
1002 fn decode_bytes_utf8_bom_is_preserved_as_leading_codepoint() {
1003 let bytes = b"\xEF\xBB\xBFhello";
1006 let decoded = decode_bytes_to_string(bytes);
1007 assert_eq!(decoded, "\u{FEFF}hello");
1008 }
1009
1010 fn utf16_with_bom(text: &str, little_endian: bool) -> Vec<u8> {
1013 let mut bytes = if little_endian {
1014 vec![0xFF, 0xFE]
1015 } else {
1016 vec![0xFE, 0xFF]
1017 };
1018 for ch in text.chars() {
1019 let unit = ch as u16;
1020 if little_endian {
1021 bytes.extend_from_slice(&unit.to_le_bytes());
1022 } else {
1023 bytes.extend_from_slice(&unit.to_be_bytes());
1024 }
1025 }
1026 bytes
1027 }
1028
1029 #[test]
1030 fn decode_bytes_utf16_le_bom_is_decoded_and_bom_stripped() {
1031 let bytes = utf16_with_bom("hello world", true);
1035 let decoded = decode_bytes_to_string(&bytes);
1036 assert_eq!(decoded, "hello world");
1037 assert!(!decoded.starts_with('\u{FEFF}'));
1038 }
1039
1040 #[test]
1041 fn decode_bytes_utf16_be_bom_is_decoded_and_bom_stripped() {
1042 let bytes = utf16_with_bom("hello world", false);
1043 let decoded = decode_bytes_to_string(&bytes);
1044 assert_eq!(decoded, "hello world");
1045 assert!(!decoded.starts_with('\u{FEFF}'));
1046 }
1047
1048 #[test]
1049 fn decode_bytes_short_utf16_bom_text_fails_text_shape_gate() {
1050 let bytes = utf16_with_bom("hi", true);
1054 assert_eq!(decode_bytes_to_string(&bytes), "");
1055 }
1056
1057 #[test]
1058 fn decode_bytes_corrupted_utf16_bom_prefix_is_stripped() {
1059 let mut bytes = CORRUPTED_UTF16_BOM_PREFIX.to_vec();
1063 for ch in "hello world test text long enough".chars() {
1064 bytes.push(ch as u8);
1065 bytes.push(0x00);
1066 }
1067 let decoded = decode_bytes_to_string(&bytes);
1068 assert_eq!(decoded, "hello world test text long enough");
1069 assert!(!decoded.contains('\u{FFFD}'));
1071 }
1072
1073 #[test]
1074 fn decoded_text_lone_cr_line_endings_are_not_split_by_lines() {
1075 let bytes = b"first line\rsecond line\rthird line";
1082 let decoded = decode_bytes_to_string(bytes);
1083 assert_eq!(decoded, "first line\rsecond line\rthird line");
1084
1085 let line_count = decoded.lines().count();
1086 assert_eq!(
1087 line_count, 1,
1088 "lone-CR line endings are currently NOT split by str::lines()"
1089 );
1090
1091 let lf = decode_bytes_to_string(b"a\nb\nc");
1093 assert_eq!(lf.lines().count(), 3);
1094 let crlf = decode_bytes_to_string(b"a\r\nb\r\nc");
1095 assert_eq!(crlf.lines().count(), 3);
1096 }
1097
1098 #[test]
1099 fn extract_text_near_binary_invalid_utf8_surfaces_skip_diagnostic() {
1100 let path = Path::new("garbled.svg");
1107 let bytes = invalid_utf8_with_control_ratio(1, 60);
1108
1109 let (text, kind, diagnostic) = extract_text_for_detection_with_diagnostics(path, &bytes);
1110
1111 assert!(text.is_empty());
1112 assert_eq!(kind, ExtractedTextKind::None);
1113 assert_eq!(diagnostic.as_deref(), Some(NEAR_BINARY_SKIP_DIAGNOSTIC));
1114 }
1115}