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