Skip to main content

provenant/parsers/debian/
copyright.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7use 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
25/// Parser for Debian machine-readable copyright files (DEP-5 format)
26pub 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 &paragraphs {
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(&para.metadata.headers, "copyright")
147            {
148                for holder in parse_copyright_holders(&copyright_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(&para.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(&para.metadata.headers, "format").is_some();
168                    if rfc822::get_header_first(&para.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, &current_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, &current_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, &copyright);
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}