1use std::collections::HashMap;
8use std::path::Path;
9
10use crate::models::{DatasourceId, LicenseDetection, LineNumber, PackageData, PackageType};
11use crate::parser_warn as warn;
12use crate::parsers::rfc822::{self, Rfc822Metadata};
13use crate::parsers::utils::{MAX_ITERATION_COUNT, truncate_field};
14use crate::utils::spdx::combine_license_expressions;
15
16use super::super::metadata::ParserMetadata;
17use super::utils::{build_debian_purl, make_party};
18use super::{PACKAGE_TYPE, default_package_data, read_or_default};
19use crate::parsers::PackageParser;
20use crate::parsers::license_normalization::{
21 DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_detection,
22 normalize_declared_license_key,
23};
24
25pub struct DebianCopyrightParser;
27
28impl PackageParser for DebianCopyrightParser {
29 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
30
31 fn metadata() -> Vec<ParserMetadata> {
32 vec![ParserMetadata {
33 description: "Debian machine-readable copyright file",
34 file_patterns: &[
35 "**/debian/copyright",
36 "**/ports/*/copyright",
37 "**/packages/deb/copyright",
38 "**/usr/share/doc/*/copyright",
39 "**/*_copyright",
40 ],
41 package_type: "deb",
42 primary_language: "",
43 documentation_url: Some(
44 "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/",
45 ),
46 }]
47 }
48
49 fn is_match(path: &Path) -> bool {
50 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
51 if filename != "copyright" {
52 return filename.ends_with("_copyright");
53 }
54 let path_str = path.to_string_lossy();
55 path_str.contains("/debian/")
56 || path_str.contains("/ports/")
57 || path_str.starts_with("ports/")
58 || path_str.contains("/packages/deb/")
59 || path_str.contains("/usr/share/doc/")
60 || path_str.ends_with("debian/copyright")
61 } else {
62 false
63 }
64 }
65
66 fn extract_packages(path: &Path) -> Vec<PackageData> {
67 let datasource_id = detect_debian_copyright_datasource(path);
68 let content = read_or_default!(path, "copyright file", datasource_id);
69
70 let package_name = extract_package_name_from_path(path)
71 .or_else(|| extract_standalone_package_name_from_path(path, datasource_id));
72 let mut package_data = parse_copyright_file(&content, package_name.as_deref());
73 package_data.datasource_id = Some(datasource_id);
74 vec![package_data]
75 }
76}
77
78fn detect_debian_copyright_datasource(path: &Path) -> DatasourceId {
79 let path_str = path.to_string_lossy();
80 if path_str.contains("/debian/") || path_str.ends_with("debian/copyright") {
81 DatasourceId::DebianCopyrightInSource
82 } else if path_str.contains("/usr/share/doc/") {
83 DatasourceId::DebianCopyrightInPackage
84 } else {
85 DatasourceId::DebianCopyrightStandalone
86 }
87}
88
89fn extract_package_name_from_path(path: &Path) -> Option<String> {
90 let components: Vec<_> = path.components().collect();
91
92 for (i, component) in components.iter().enumerate() {
93 if let std::path::Component::Normal(os_str) = component
94 && os_str.to_str() == Some("doc")
95 && i + 1 < components.len()
96 && let std::path::Component::Normal(next) = components[i + 1]
97 {
98 return next.to_str().map(|s| s.to_string());
99 }
100 }
101 None
102}
103
104fn extract_standalone_package_name_from_path(
105 path: &Path,
106 datasource_id: DatasourceId,
107) -> Option<String> {
108 if datasource_id != DatasourceId::DebianCopyrightStandalone {
109 return None;
110 }
111
112 path.file_name()
113 .and_then(|name| name.to_str())
114 .filter(|name| *name == "copyright")?;
115
116 path.parent()
117 .and_then(|parent| parent.file_name())
118 .and_then(|name| name.to_str())
119 .map(str::to_string)
120}
121
122pub(super) fn parse_copyright_file(content: &str, package_name: Option<&str>) -> PackageData {
123 let paragraphs = parse_copyright_paragraphs_with_lines(content);
124
125 let is_dep5 = paragraphs
126 .first()
127 .and_then(|p| rfc822::get_header_first(&p.metadata.headers, "format"))
128 .is_some();
129
130 let namespace = Some("debian".to_string());
131 let mut parties = Vec::new();
132 let mut license_statements = Vec::new();
133 let mut primary_license_detection = None;
134 let mut header_license_detection = None;
135 let mut other_license_detections = Vec::new();
136
137 if is_dep5 {
138 let mut para_count = 0usize;
139 for para in ¶graphs {
140 para_count += 1;
141 if para_count > MAX_ITERATION_COUNT {
142 warn!("parse_copyright_file: exceeded MAX_ITERATION_COUNT paragraphs, stopping");
143 break;
144 }
145 if let Some(copyright_text) =
146 rfc822::get_header_first(¶.metadata.headers, "copyright")
147 {
148 for holder in parse_copyright_holders(©right_text) {
149 if !holder.is_empty() {
150 parties.push(make_party(None, "copyright-holder", Some(holder), None));
151 }
152 }
153 }
154
155 if let Some(license) = rfc822::get_header_first(¶.metadata.headers, "license") {
156 let license_name = license.lines().next().unwrap_or(&license).trim();
157 if !license_name.is_empty()
158 && !license_statements.contains(&license_name.to_string())
159 {
160 license_statements.push(license_name.to_string());
161 }
162
163 if let Some((matched_text, line_no)) = para.license_header_line.clone() {
164 let detection =
165 build_primary_license_detection(license_name, matched_text, line_no);
166 let is_header_paragraph =
167 rfc822::get_header_first(¶.metadata.headers, "format").is_some();
168 if rfc822::get_header_first(¶.metadata.headers, "files").as_deref()
169 == Some("*")
170 {
171 primary_license_detection = Some(detection);
172 } else if is_header_paragraph {
173 header_license_detection.get_or_insert(detection);
174 } else {
175 other_license_detections.push(detection);
176 }
177 }
178 }
179 }
180
181 if primary_license_detection.is_none() && header_license_detection.is_some() {
182 primary_license_detection = header_license_detection;
183 }
184 } else {
185 let copyright_block = extract_unstructured_field(content, "Copyright:");
186 if let Some(text) = copyright_block {
187 for holder in parse_copyright_holders(&text) {
188 if !holder.is_empty() {
189 parties.push(make_party(None, "copyright-holder", Some(holder), None));
190 }
191 }
192 }
193
194 let license_block = extract_unstructured_field(content, "License:");
195 if let Some(text) = license_block {
196 license_statements.push(text.lines().next().unwrap_or(&text).trim().to_string());
197 }
198 }
199
200 let extracted_license_statement = if license_statements.is_empty() {
201 None
202 } else {
203 Some(truncate_field(license_statements.join(" AND ")))
204 };
205
206 let license_detections = primary_license_detection.into_iter().collect::<Vec<_>>();
207 let declared_license_expression = license_detections
208 .first()
209 .map(|detection| detection.license_expression.clone());
210 let declared_license_expression_spdx = license_detections
211 .first()
212 .map(|detection| detection.license_expression_spdx.clone());
213 let other_license_expression = combine_license_expressions(
214 other_license_detections
215 .iter()
216 .map(|detection| detection.license_expression.clone()),
217 );
218 let other_license_expression_spdx = combine_license_expressions(
219 other_license_detections
220 .iter()
221 .map(|detection| detection.license_expression_spdx.clone()),
222 );
223
224 PackageData {
225 datasource_id: Some(DatasourceId::DebianCopyright),
226 package_type: Some(PACKAGE_TYPE),
227 namespace: namespace.clone(),
228 name: package_name.map(|s| truncate_field(s.to_string())),
229 parties,
230 declared_license_expression,
231 declared_license_expression_spdx,
232 license_detections,
233 other_license_expression,
234 other_license_expression_spdx,
235 other_license_detections,
236 extracted_license_statement,
237 purl: package_name.and_then(|n| build_debian_purl(n, None, namespace.as_deref(), None)),
238 ..Default::default()
239 }
240}
241
242#[derive(Debug)]
243struct CopyrightParagraph {
244 metadata: Rfc822Metadata,
245 license_header_line: Option<(String, usize)>,
246}
247
248fn parse_copyright_paragraphs_with_lines(content: &str) -> Vec<CopyrightParagraph> {
249 let mut paragraphs = Vec::new();
250 let mut current_lines = Vec::new();
251 let mut current_start_line = 1usize;
252 let mut count = 0usize;
253
254 for (idx, line) in content.lines().enumerate() {
255 count += 1;
256 if count > MAX_ITERATION_COUNT {
257 warn!(
258 "parse_copyright_paragraphs_with_lines: exceeded MAX_ITERATION_COUNT lines, stopping"
259 );
260 break;
261 }
262 let line_no = idx + 1;
263 if line.is_empty() {
264 if !current_lines.is_empty() {
265 paragraphs.push(finalize_copyright_paragraph(
266 std::mem::take(&mut current_lines),
267 current_start_line,
268 ));
269 }
270 current_start_line = line_no + 1;
271 } else {
272 if current_lines.is_empty() {
273 current_start_line = line_no;
274 }
275 current_lines.push(line.to_string());
276 }
277 }
278
279 if !current_lines.is_empty() {
280 paragraphs.push(finalize_copyright_paragraph(
281 current_lines,
282 current_start_line,
283 ));
284 }
285
286 paragraphs
287}
288
289fn finalize_copyright_paragraph(raw_lines: Vec<String>, start_line: usize) -> CopyrightParagraph {
290 let mut headers: HashMap<String, Vec<String>> = HashMap::new();
291 let mut current_name: Option<String> = None;
292 let mut current_value = String::new();
293 let mut license_header_line = None;
294
295 for (idx, line) in raw_lines.iter().enumerate() {
296 if line.starts_with(' ') || line.starts_with('\t') {
297 if current_name.is_some() {
298 current_value.push('\n');
299 current_value.push_str(line);
300 }
301 continue;
302 }
303
304 if let Some(name) = current_name.take() {
305 add_copyright_header_value(&mut headers, &name, ¤t_value);
306 current_value.clear();
307 }
308
309 if let Some((name, value)) = line.split_once(':') {
310 let normalized_name = name.trim().to_ascii_lowercase();
311 if normalized_name == "license" && license_header_line.is_none() {
312 license_header_line = Some((line.trim_end().to_string(), start_line + idx));
313 }
314 current_name = Some(normalized_name);
315 current_value = value.trim_start().to_string();
316 }
317 }
318
319 if let Some(name) = current_name.take() {
320 add_copyright_header_value(&mut headers, &name, ¤t_value);
321 }
322
323 CopyrightParagraph {
324 metadata: Rfc822Metadata {
325 headers,
326 body: String::new(),
327 },
328 license_header_line,
329 }
330}
331
332fn add_copyright_header_value(headers: &mut HashMap<String, Vec<String>>, name: &str, value: &str) {
333 let entry = headers.entry(name.to_string()).or_default();
334 let trimmed = value.trim_end();
335 if !trimmed.is_empty() {
336 entry.push(trimmed.to_string());
337 }
338}
339
340fn build_primary_license_detection(
341 license_name: &str,
342 matched_text: String,
343 line_no: usize,
344) -> LicenseDetection {
345 let normalized = normalize_debian_license_name(license_name);
346 let line = match LineNumber::new(line_no) {
347 Some(l) => l,
348 None => {
349 warn!(
350 "build_primary_license_detection: line number {} out of range, clamping to 1",
351 line_no
352 );
353 LineNumber::new(1).expect("1 is a valid line number")
354 }
355 };
356
357 build_declared_license_detection(
358 &normalized,
359 DeclaredLicenseMatchMetadata::new(&matched_text, line, line),
360 )
361}
362
363fn normalize_debian_license_name(license_name: &str) -> NormalizedDeclaredLicense {
364 match license_name.trim() {
365 "GPL-2+" => NormalizedDeclaredLicense::new("gpl-2.0-plus", "GPL-2.0-or-later"),
366 "GPL-2" => NormalizedDeclaredLicense::new("gpl-2.0", "GPL-2.0-only"),
367 "LGPL-2+" => NormalizedDeclaredLicense::new("lgpl-2.0-plus", "LGPL-2.0-or-later"),
368 "LGPL-2.1" => NormalizedDeclaredLicense::new("lgpl-2.1", "LGPL-2.1-only"),
369 "LGPL-2.1+" => NormalizedDeclaredLicense::new("lgpl-2.1-plus", "LGPL-2.1-or-later"),
370 "LGPL-3+" => NormalizedDeclaredLicense::new("lgpl-3.0-plus", "LGPL-3.0-or-later"),
371 "BSD-4-clause" => NormalizedDeclaredLicense::new("bsd-original-uc", "BSD-4-Clause-UC"),
372 "public-domain" => {
373 NormalizedDeclaredLicense::new("public-domain", "LicenseRef-scancode-public-domain")
374 }
375 other => normalize_declared_license_key(other)
376 .unwrap_or_else(|| NormalizedDeclaredLicense::new(other.to_ascii_lowercase(), other)),
377 }
378}
379
380fn parse_copyright_holders(text: &str) -> Vec<String> {
381 let mut holders = Vec::new();
382 let mut count = 0usize;
383
384 for line in text.lines() {
385 count += 1;
386 if count > MAX_ITERATION_COUNT {
387 warn!("parse_copyright_holders: exceeded MAX_ITERATION_COUNT lines, stopping");
388 break;
389 }
390 let line = line.trim();
391 if line.is_empty() {
392 continue;
393 }
394
395 let cleaned = line
396 .trim_start_matches("Copyright")
397 .trim_start_matches("copyright")
398 .trim_start_matches("(C)")
399 .trim_start_matches("(c)")
400 .trim_start_matches("©")
401 .trim();
402
403 if let Some(year_end) = cleaned.find(char::is_alphabetic) {
404 let without_years = &cleaned[year_end..];
405 let holder = without_years
406 .trim_start_matches(',')
407 .trim_start_matches('-')
408 .trim();
409
410 if !holder.is_empty() && holder.len() > 2 {
411 holders.push(holder.to_string());
412 }
413 }
414 }
415
416 holders
417}
418
419fn extract_unstructured_field(content: &str, field_name: &str) -> Option<String> {
420 let mut in_field = false;
421 let mut field_content = String::new();
422 let mut count = 0usize;
423
424 for line in content.lines() {
425 count += 1;
426 if count > MAX_ITERATION_COUNT {
427 warn!("extract_unstructured_field: exceeded MAX_ITERATION_COUNT lines, stopping");
428 break;
429 }
430 if line.starts_with(field_name) {
431 in_field = true;
432 field_content.push_str(line.trim_start_matches(field_name).trim());
433 field_content.push('\n');
434 } else if in_field {
435 if line.starts_with(char::is_whitespace) {
436 field_content.push_str(line.trim());
437 field_content.push('\n');
438 } else if !line.trim().is_empty() {
439 break;
440 }
441 }
442 }
443
444 let trimmed = field_content.trim();
445 if trimmed.is_empty() {
446 None
447 } else {
448 Some(truncate_field(trimmed.to_string()))
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::super::deb::merge_debian_copyright_into_package;
455 use super::super::default_package_data;
456 use super::*;
457 use crate::models::DatasourceId;
458 use crate::models::LineNumber;
459 use std::path::PathBuf;
460
461 #[test]
462 fn test_copyright_parser_is_match() {
463 assert!(DebianCopyrightParser::is_match(&PathBuf::from(
464 "/usr/share/doc/bash/copyright"
465 )));
466 assert!(DebianCopyrightParser::is_match(&PathBuf::from(
467 "debian/copyright"
468 )));
469 assert!(DebianCopyrightParser::is_match(&PathBuf::from(
470 "src/third_party/gperftools/dist/packages/deb/copyright"
471 )));
472 assert!(DebianCopyrightParser::is_match(&PathBuf::from(
473 "ports/zlib/copyright"
474 )));
475 assert!(!DebianCopyrightParser::is_match(&PathBuf::from(
476 "copyright.txt"
477 )));
478 assert!(!DebianCopyrightParser::is_match(&PathBuf::from(
479 "/etc/copyright"
480 )));
481 assert!(DebianCopyrightParser::is_match(&PathBuf::from(
482 "/tmp/sample_copyright"
483 )));
484 }
485
486 #[test]
487 fn test_detect_debian_copyright_datasource() {
488 assert_eq!(
489 detect_debian_copyright_datasource(&PathBuf::from("debian/copyright")),
490 DatasourceId::DebianCopyrightInSource
491 );
492 assert_eq!(
493 detect_debian_copyright_datasource(&PathBuf::from(
494 "src/third_party/gperftools/dist/packages/deb/copyright"
495 )),
496 DatasourceId::DebianCopyrightStandalone
497 );
498 assert_eq!(
499 detect_debian_copyright_datasource(&PathBuf::from("ports/zlib/copyright")),
500 DatasourceId::DebianCopyrightStandalone
501 );
502 assert_eq!(
503 detect_debian_copyright_datasource(&PathBuf::from("/usr/share/doc/bash/copyright")),
504 DatasourceId::DebianCopyrightInPackage
505 );
506 assert_eq!(
507 detect_debian_copyright_datasource(&PathBuf::from("stable_copyright")),
508 DatasourceId::DebianCopyrightStandalone
509 );
510 }
511
512 #[test]
513 fn test_extract_package_name_from_path() {
514 assert_eq!(
515 extract_package_name_from_path(&PathBuf::from("/usr/share/doc/bash/copyright")),
516 Some("bash".to_string())
517 );
518 assert_eq!(
519 extract_package_name_from_path(&PathBuf::from("/usr/share/doc/libseccomp2/copyright")),
520 Some("libseccomp2".to_string())
521 );
522 assert_eq!(
523 extract_package_name_from_path(&PathBuf::from("debian/copyright")),
524 None
525 );
526 assert_eq!(
527 extract_standalone_package_name_from_path(
528 &PathBuf::from("ports/zlib/copyright"),
529 DatasourceId::DebianCopyrightStandalone,
530 ),
531 Some("zlib".to_string())
532 );
533 }
534
535 #[test]
536 fn test_parse_copyright_dep5_format() {
537 let content = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
538Upstream-Name: libseccomp
539Source: https://sourceforge.net/projects/libseccomp/
540
541Files: *
542Copyright: 2012 Paul Moore <pmoore@redhat.com>
543 2012 Ashley Lai <adlai@us.ibm.com>
544License: LGPL-2.1
545
546License: LGPL-2.1
547 This library is free software
548";
549 let pkg = parse_copyright_file(content, Some("libseccomp"));
550 assert_eq!(pkg.name, Some("libseccomp".to_string()));
551 assert_eq!(pkg.namespace, Some("debian".to_string()));
552 assert_eq!(pkg.datasource_id, Some(DatasourceId::DebianCopyright));
553 assert_eq!(
554 pkg.extracted_license_statement,
555 Some("LGPL-2.1".to_string())
556 );
557 assert!(pkg.parties.len() >= 2);
558 assert_eq!(pkg.parties[0].role, Some("copyright-holder".to_string()));
559 assert!(pkg.parties[0].name.as_ref().unwrap().contains("Paul Moore"));
560 }
561
562 #[test]
563 fn test_parse_copyright_primary_license_detection_from_bsdutils_fixture() {
564 let path = PathBuf::from(
565 "testdata/debian-fixtures/debian-slim-2021-04-07/usr/share/doc/bsdutils/copyright",
566 );
567 let pkg = DebianCopyrightParser::extract_first_package(&path);
568
569 assert_eq!(pkg.name, Some("bsdutils".to_string()));
570 let extracted = pkg
571 .extracted_license_statement
572 .as_deref()
573 .expect("license statement should exist");
574 assert!(extracted.contains("GPL-2+"));
575 assert!(!pkg.license_detections.is_empty());
576
577 let primary = &pkg.license_detections[0];
578 assert_eq!(
579 primary.matches[0].matched_text.as_deref(),
580 Some("License: GPL-2+")
581 );
582 assert_eq!(primary.matches[0].start_line, LineNumber::new(47).unwrap());
583 assert_eq!(primary.matches[0].end_line, LineNumber::new(47).unwrap());
584 }
585
586 #[test]
587 fn test_parse_copyright_emits_ordered_absolute_case_preserved_detections() {
588 let path = PathBuf::from("testdata/debian/copyright/copyright");
589 let pkg = DebianCopyrightParser::extract_first_package(&path);
590
591 assert_eq!(pkg.license_detections.len(), 1);
592 assert_eq!(pkg.other_license_detections.len(), 4);
593
594 let primary = &pkg.license_detections[0];
595 assert_eq!(
596 primary.matches[0].matched_text.as_deref(),
597 Some("License: LGPL-2.1")
598 );
599 assert_eq!(primary.matches[0].start_line, LineNumber::new(11).unwrap());
600
601 let ordered_lines: Vec<usize> = pkg
602 .other_license_detections
603 .iter()
604 .map(|detection| detection.matches[0].start_line.get())
605 .collect();
606 assert_eq!(ordered_lines, vec![15, 19, 23, 25]);
607
608 let ordered_texts: Vec<&str> = pkg
609 .other_license_detections
610 .iter()
611 .map(|detection| detection.matches[0].matched_text.as_deref().unwrap())
612 .collect();
613 assert_eq!(
614 ordered_texts,
615 vec![
616 "License: LGPL-2.1",
617 "License: LGPL-2.1",
618 "License: LGPL-2.1",
619 "License: LGPL-2.1",
620 ]
621 );
622 }
623
624 #[test]
625 fn test_parse_copyright_detects_bottom_standalone_license_paragraph() {
626 let path = PathBuf::from(
627 "testdata/debian-fixtures/debian-2019-11-15/main/c/clamav/stable_copyright",
628 );
629 let pkg = DebianCopyrightParser::extract_first_package(&path);
630
631 let zlib = pkg
632 .other_license_detections
633 .iter()
634 .find(|detection| detection.matches[0].matched_text.as_deref() == Some("License: Zlib"))
635 .expect("at least one Zlib license paragraph should be detected");
636 assert_eq!(
637 zlib.matches[0].matched_text.as_deref(),
638 Some("License: Zlib")
639 );
640
641 let last_zlib = pkg
642 .other_license_detections
643 .iter()
644 .rev()
645 .find(|detection| detection.matches[0].matched_text.as_deref() == Some("License: Zlib"))
646 .expect("bottom standalone Zlib license paragraph should be detected");
647 assert_eq!(
648 last_zlib.matches[0].start_line,
649 LineNumber::new(732).unwrap()
650 );
651 assert_eq!(last_zlib.matches[0].end_line, LineNumber::new(732).unwrap());
652 }
653
654 #[test]
655 fn test_parse_copyright_uses_header_paragraph_as_primary_when_files_star_is_blank() {
656 let path =
657 PathBuf::from("testdata/debian-fixtures/crafted_for_tests/test_license_nameless");
658 let pkg = DebianCopyrightParser::extract_first_package(&path);
659
660 assert_eq!(pkg.license_detections.len(), 1);
661 let primary = &pkg.license_detections[0];
662 assert_eq!(
663 primary.matches[0].matched_text.as_deref(),
664 Some("License: LGPL-3+ or GPL-2+")
665 );
666 assert_eq!(primary.matches[0].start_line, LineNumber::new(8).unwrap());
667 assert_eq!(primary.matches[0].end_line, LineNumber::new(8).unwrap());
668
669 assert!(pkg.other_license_detections.iter().any(|detection| {
670 detection.matches[0].matched_text.as_deref() == Some("License: GPL-2+")
671 }));
672 }
673
674 #[test]
675 fn test_parse_copyright_prefers_files_star_primary_over_header_paragraph() {
676 let content = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\nUpstream-Name: foo\nLicense: MIT\n\nFiles: *\nCopyright: 2024 Example\nLicense: GPL-2+\n";
677 let pkg = parse_copyright_file(content, Some("foo"));
678
679 assert_eq!(pkg.license_detections.len(), 1);
680 let primary = &pkg.license_detections[0];
681 assert_eq!(
682 primary.matches[0].matched_text.as_deref(),
683 Some("License: GPL-2+")
684 );
685 assert_eq!(primary.matches[0].start_line, LineNumber::new(7).unwrap());
686 }
687
688 #[test]
689 fn test_finalize_copyright_paragraph_matches_rfc822_headers_and_license_line() {
690 let raw_lines = vec![
691 "Files: *".to_string(),
692 "Copyright: 2024 Example Org".to_string(),
693 "License: Apache-2.0".to_string(),
694 " Licensed under the Apache License, Version 2.0.".to_string(),
695 ];
696
697 let paragraph = finalize_copyright_paragraph(raw_lines.clone(), 10);
698 let expected = rfc822::parse_rfc822_paragraphs(&raw_lines.join("\n"))
699 .into_iter()
700 .next()
701 .expect("reference RFC822 paragraph should parse");
702
703 assert_eq!(paragraph.metadata.headers, expected.headers);
704 assert_eq!(paragraph.metadata.body, expected.body);
705 assert_eq!(
706 paragraph.license_header_line,
707 Some(("License: Apache-2.0".to_string(), 12))
708 );
709 }
710
711 #[test]
712 fn test_parse_copyright_unstructured() {
713 let content = "This package was debianized by John Doe.
714
715Upstream Authors:
716 Jane Smith
717
718Copyright:
719 2009 10gen
720
721License:
722 SSPL
723";
724 let pkg = parse_copyright_file(content, Some("mongodb"));
725 assert_eq!(pkg.name, Some("mongodb".to_string()));
726 assert_eq!(pkg.extracted_license_statement, Some("SSPL".to_string()));
727 assert!(!pkg.parties.is_empty());
728 }
729
730 #[test]
731 fn test_parse_copyright_holders() {
732 let text = "2012 Paul Moore <pmoore@redhat.com>
7332012 Ashley Lai <adlai@us.ibm.com>
734Copyright (C) 2015-2018 Example Corp";
735 let holders = parse_copyright_holders(text);
736 assert!(holders.len() >= 3);
737 assert!(holders.iter().any(|h| h.contains("Paul Moore")));
738 assert!(holders.iter().any(|h| h.contains("Example Corp")));
739 }
740
741 #[test]
742 fn test_parse_copyright_empty() {
743 let content = "This is just some text without proper copyright info.";
744 let pkg = parse_copyright_file(content, Some("test"));
745 assert_eq!(pkg.name, Some("test".to_string()));
746 assert!(pkg.parties.is_empty());
747 assert!(pkg.extracted_license_statement.is_none());
748 }
749
750 #[test]
751 fn test_merge_debian_copyright_into_package_preserves_license_fields() {
752 let copyright = parse_copyright_file(
753 "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\
754 Upstream-Name: demo\n\n\
755 Files: *\n\
756 Copyright: 2024 Example\n\
757 License: MIT\n\n\
758 Files: debian/*\n\
759 Copyright: 2024 Debian Example\n\
760 License: Apache-2.0\n",
761 Some("demo"),
762 );
763 let mut target = default_package_data(DatasourceId::DebianDeb);
764
765 merge_debian_copyright_into_package(&mut target, ©right);
766
767 assert_eq!(target.declared_license_expression.as_deref(), Some("mit"));
768 assert_eq!(
769 target.declared_license_expression_spdx.as_deref(),
770 Some("MIT")
771 );
772 assert_eq!(
773 target.other_license_expression.as_deref(),
774 Some("apache-2.0")
775 );
776 assert_eq!(
777 target.other_license_expression_spdx.as_deref(),
778 Some("Apache-2.0")
779 );
780 assert_eq!(target.license_detections.len(), 1);
781 assert_eq!(target.other_license_detections.len(), 1);
782 }
783
784 #[test]
785 fn test_normalize_debian_public_domain_uses_scancode_license_ref() {
786 let normalized = normalize_debian_license_name("public-domain");
787
788 assert_eq!(normalized.declared_license_expression, "public-domain");
789 assert_eq!(
790 normalized.declared_license_expression_spdx,
791 "LicenseRef-scancode-public-domain"
792 );
793 }
794}