Skip to main content

provenant/parsers/debian/
copyright.rs

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