Skip to main content

provenant/parsers/
bitbake.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::models::{
8    DatasourceId, Dependency, FileReference, Md5Digest, PackageData, PackageType, Sha1Digest,
9    Sha256Digest, Sha512Digest,
10};
11use crate::parser_warn as warn;
12use packageurl::PackageUrl;
13use serde_json::Value;
14
15use super::PackageParser;
16use super::license_normalization::normalize_spdx_declared_license;
17use super::metadata::ParserMetadata;
18use super::utils::{read_file_to_string, truncate_field};
19
20pub struct BitbakeRecipeParser;
21
22impl PackageParser for BitbakeRecipeParser {
23    const PACKAGE_TYPE: PackageType = PackageType::Bitbake;
24
25    fn metadata() -> Vec<ParserMetadata> {
26        vec![
27            ParserMetadata {
28                description: "Yocto BitBake recipe",
29                file_patterns: &["**/*.bb"],
30                package_type: "bitbake",
31                primary_language: "Shell",
32                documentation_url: Some(
33                    "https://docs.yoctoproject.org/bitbake/bitbake-user-manual/bitbake-user-manual-metadata.html",
34                ),
35            },
36            ParserMetadata {
37                description: "Yocto BitBake append file",
38                file_patterns: &["**/*.bbappend"],
39                package_type: "bitbake",
40                primary_language: "Shell",
41                documentation_url: Some(
42                    "https://docs.yoctoproject.org/bitbake/bitbake-user-manual/bitbake-user-manual-metadata.html",
43                ),
44            },
45        ]
46    }
47
48    fn is_match(path: &Path) -> bool {
49        path.extension()
50            .and_then(|ext| ext.to_str())
51            .is_some_and(|ext| matches!(ext, "bb" | "bbappend"))
52    }
53
54    fn extract_packages(path: &Path) -> Vec<PackageData> {
55        let datasource_id = datasource_id_for_path(path);
56        let content = match read_file_to_string(path, None) {
57            Ok(content) => content,
58            Err(error) => {
59                warn!("Failed to read BitBake recipe at {:?}: {}", path, error);
60                return vec![default_package_data(datasource_id)];
61            }
62        };
63
64        vec![parse_recipe(&content, path, datasource_id)]
65    }
66}
67
68fn datasource_id_for_path(path: &Path) -> DatasourceId {
69    match path.extension().and_then(|ext| ext.to_str()) {
70        Some("bbappend") => DatasourceId::BitbakeRecipeAppend,
71        _ => DatasourceId::BitbakeRecipe,
72    }
73}
74
75fn parse_recipe(content: &str, path: &Path, datasource_id: DatasourceId) -> PackageData {
76    let vars = extract_variables(content);
77    let (filename_name, filename_version) = parse_recipe_filename(path);
78
79    let mut package = default_package_data(datasource_id);
80    let mut extra_data: HashMap<String, Value> = HashMap::new();
81
82    let name = vars
83        .get("PN")
84        .cloned()
85        .or(filename_name)
86        .map(truncate_field);
87    let version = vars
88        .get("PV")
89        .cloned()
90        .or(filename_version)
91        .map(truncate_field);
92
93    package.name = name.clone();
94    package.version = version.clone();
95
96    if let Some(summary) = vars.get("SUMMARY") {
97        package.description = Some(truncate_field(summary.clone()));
98    } else if let Some(description) = vars.get("DESCRIPTION") {
99        package.description = Some(truncate_field(description.clone()));
100    }
101
102    if let Some(homepage) = vars.get("HOMEPAGE") {
103        package.homepage_url = Some(truncate_field(homepage.clone()));
104    }
105
106    if let Some(bugtracker) = vars.get("BUGTRACKER") {
107        package.bug_tracking_url = Some(truncate_field(bugtracker.clone()));
108    }
109
110    if let Some(license) = select_license_value(&vars, name.as_deref()) {
111        package.extracted_license_statement = Some(truncate_field(license.clone()));
112
113        let normalized = normalize_bitbake_license(&license);
114        let (declared, spdx, detections) =
115            normalize_spdx_declared_license(Some(normalized.as_str()));
116        package.declared_license_expression = declared;
117        package.declared_license_expression_spdx = spdx;
118        package.license_detections = detections;
119    }
120
121    if let Some(section) = vars.get("SECTION") {
122        extra_data.insert("section".to_string(), Value::String(section.clone()));
123    }
124
125    let mut file_references = Vec::new();
126    if let Some(lic_files) = vars.get("LIC_FILES_CHKSUM") {
127        merge_file_references(
128            &mut file_references,
129            extract_lic_files_chksum_references(lic_files),
130        );
131    }
132
133    if let Some(src_uri) = vars.get("SRC_URI") {
134        let (remote_entries, local_references) = extract_src_uri_data(src_uri);
135        let uris: Vec<String> = remote_entries
136            .iter()
137            .map(|entry| entry.uri.clone())
138            .collect();
139        if !uris.is_empty() {
140            extra_data.insert(
141                "src_uri".to_string(),
142                Value::Array(uris.into_iter().map(Value::String).collect()),
143            );
144        }
145        merge_file_references(&mut file_references, local_references);
146        apply_src_uri_package_metadata(&mut package, &vars, &remote_entries);
147    }
148
149    let inherits = extract_inherits(content);
150    if !inherits.is_empty() {
151        extra_data.insert(
152            "inherit".to_string(),
153            Value::Array(inherits.into_iter().map(Value::String).collect()),
154        );
155    }
156
157    let mut dependencies = Vec::new();
158
159    if let Some(depends) = vars.get("DEPENDS") {
160        dependencies.extend(
161            parse_dependency_list(depends)
162                .into_iter()
163                .map(|dependency| Dependency {
164                    purl: build_dependency_purl(&dependency.name),
165                    extracted_requirement: dependency.requirement,
166                    scope: Some("build".to_string()),
167                    is_runtime: Some(false),
168                    is_optional: None,
169                    is_pinned: None,
170                    is_direct: Some(true),
171                    resolved_package: None,
172                    extra_data: None,
173                }),
174        );
175    }
176
177    for (key, value) in &vars {
178        if is_rdepends_key(key) {
179            dependencies.extend(parse_dependency_list(value).into_iter().map(|dependency| {
180                Dependency {
181                    purl: build_dependency_purl(&dependency.name),
182                    extracted_requirement: dependency.requirement,
183                    scope: Some("runtime".to_string()),
184                    is_runtime: Some(true),
185                    is_optional: None,
186                    is_pinned: None,
187                    is_direct: Some(true),
188                    resolved_package: None,
189                    extra_data: None,
190                }
191            }));
192        }
193    }
194
195    package.dependencies = dependencies;
196    package.file_references = file_references;
197    package.extra_data = (!extra_data.is_empty()).then_some(extra_data);
198    package.purl = name
199        .as_deref()
200        .and_then(|n| build_package_purl(n, version.as_deref()));
201
202    package
203}
204
205fn default_package_data(datasource_id: DatasourceId) -> PackageData {
206    PackageData {
207        package_type: Some(PackageType::Bitbake),
208        datasource_id: Some(datasource_id),
209        ..Default::default()
210    }
211}
212
213fn parse_recipe_filename(path: &Path) -> (Option<String>, Option<String>) {
214    let stem = match path.file_stem().and_then(|s| s.to_str()) {
215        Some(s) => s,
216        None => return (None, None),
217    };
218
219    match stem.split_once('_') {
220        Some((name, version)) if !name.is_empty() && !version.is_empty() => {
221            let version = (!version.contains('%')).then_some(version.to_string());
222            (Some(name.to_string()), version)
223        }
224        _ => {
225            let trimmed_stem = stem.trim_end_matches('%');
226            let name = if trimmed_stem.is_empty() {
227                stem.to_string()
228            } else {
229                trimmed_stem.to_string()
230            };
231            (Some(name), None)
232        }
233    }
234}
235
236fn select_license_value(
237    vars: &HashMap<String, String>,
238    package_name: Option<&str>,
239) -> Option<String> {
240    let mut candidate_keys = Vec::new();
241
242    if let Some(package_name) = package_name {
243        candidate_keys.push(format!("LICENSE:{package_name}"));
244        candidate_keys.push(format!("LICENSE_{package_name}"));
245    }
246
247    candidate_keys.extend([
248        "LICENSE:${PN}".to_string(),
249        "LICENSE_${PN}".to_string(),
250        "LICENSE".to_string(),
251    ]);
252
253    candidate_keys
254        .into_iter()
255        .find_map(|candidate| vars.get(&candidate).cloned())
256}
257
258fn apply_src_uri_package_metadata(
259    package: &mut PackageData,
260    vars: &HashMap<String, String>,
261    remote_entries: &[SrcUriEntry],
262) {
263    if remote_entries.len() != 1 {
264        return;
265    }
266
267    let entry = &remote_entries[0];
268    package.download_url = Some(entry.uri.clone());
269    package.sha1 = parse_sha1_digest(
270        entry
271            .sha1sum
272            .as_deref()
273            .or_else(|| src_uri_varflag_value(vars, entry.name.as_deref(), "sha1sum")),
274    );
275    package.md5 = parse_md5_digest(
276        entry
277            .md5sum
278            .as_deref()
279            .or_else(|| src_uri_varflag_value(vars, entry.name.as_deref(), "md5sum")),
280    );
281    package.sha256 = parse_sha256_digest(
282        entry
283            .sha256sum
284            .as_deref()
285            .or_else(|| src_uri_varflag_value(vars, entry.name.as_deref(), "sha256sum")),
286    );
287    package.sha512 = parse_sha512_digest(
288        entry
289            .sha512sum
290            .as_deref()
291            .or_else(|| src_uri_varflag_value(vars, entry.name.as_deref(), "sha512sum")),
292    );
293}
294
295fn src_uri_varflag_value<'a>(
296    vars: &'a HashMap<String, String>,
297    name: Option<&str>,
298    algorithm: &str,
299) -> Option<&'a str> {
300    name.and_then(|name| vars.get(&format!("SRC_URI[{name}.{algorithm}]")))
301        .or_else(|| vars.get(&format!("SRC_URI[{algorithm}]")))
302        .map(String::as_str)
303}
304
305fn parse_sha1_digest(value: Option<&str>) -> Option<Sha1Digest> {
306    value.and_then(|value| Sha1Digest::from_hex(value).ok())
307}
308
309fn parse_md5_digest(value: Option<&str>) -> Option<Md5Digest> {
310    value.and_then(|value| Md5Digest::from_hex(value).ok())
311}
312
313fn parse_sha256_digest(value: Option<&str>) -> Option<Sha256Digest> {
314    value.and_then(|value| Sha256Digest::from_hex(value).ok())
315}
316
317fn parse_sha512_digest(value: Option<&str>) -> Option<Sha512Digest> {
318    value.and_then(|value| Sha512Digest::from_hex(value).ok())
319}
320
321#[derive(Default)]
322struct OverrideMutations {
323    appends: Vec<String>,
324    prepends: Vec<String>,
325    removes: Vec<String>,
326}
327
328fn extract_variables(content: &str) -> HashMap<String, String> {
329    let mut vars: HashMap<String, String> = HashMap::new();
330    let mut override_mutations: HashMap<String, OverrideMutations> = HashMap::new();
331    let mut lines = content.lines().peekable();
332
333    while let Some(line) = lines.next() {
334        let trimmed = line.trim();
335
336        if trimmed.is_empty() || trimmed.starts_with('#') {
337            continue;
338        }
339
340        let mut full_line = trimmed.to_string();
341        while full_line.ends_with('\\') {
342            full_line.truncate(full_line.len() - 1);
343            if let Some(next) = lines.next() {
344                full_line.push(' ');
345                full_line.push_str(next.trim());
346            } else {
347                break;
348            }
349        }
350
351        if let Some((var_name, value, op)) = parse_assignment(&full_line) {
352            let cleaned = strip_quotes(&value);
353            match op {
354                AssignOp::Set | AssignOp::Immediate => {
355                    vars.insert(var_name, cleaned);
356                }
357                AssignOp::WeakSet | AssignOp::WeakDefault => {
358                    vars.entry(var_name).or_insert(cleaned);
359                }
360                AssignOp::Append => {
361                    vars.entry(var_name.clone())
362                        .and_modify(|v| {
363                            v.push(' ');
364                            v.push_str(&cleaned);
365                        })
366                        .or_insert(cleaned);
367                }
368                AssignOp::Prepend => {
369                    vars.entry(var_name.clone())
370                        .and_modify(|v| {
371                            let mut new = cleaned.clone();
372                            new.push(' ');
373                            new.push_str(v);
374                            *v = new;
375                        })
376                        .or_insert(cleaned);
377                }
378                AssignOp::AppendNoSpace => {
379                    vars.entry(var_name.clone())
380                        .and_modify(|v| v.push_str(&cleaned))
381                        .or_insert(cleaned);
382                }
383                AssignOp::PrependNoSpace => {
384                    vars.entry(var_name.clone())
385                        .and_modify(|v| {
386                            let mut new = cleaned.clone();
387                            new.push_str(v);
388                            *v = new;
389                        })
390                        .or_insert(cleaned);
391                }
392                AssignOp::OverrideAppend => {
393                    override_mutations
394                        .entry(var_name)
395                        .or_default()
396                        .appends
397                        .push(cleaned);
398                }
399                AssignOp::OverridePrepend => {
400                    override_mutations
401                        .entry(var_name)
402                        .or_default()
403                        .prepends
404                        .push(cleaned);
405                }
406                AssignOp::OverrideRemove => {
407                    override_mutations
408                        .entry(var_name)
409                        .or_default()
410                        .removes
411                        .push(cleaned);
412                }
413            }
414        }
415    }
416
417    apply_override_mutations(&mut vars, override_mutations);
418
419    vars
420}
421
422fn apply_override_mutations(
423    vars: &mut HashMap<String, String>,
424    override_mutations: HashMap<String, OverrideMutations>,
425) {
426    for (var_name, mutations) in override_mutations {
427        let value = vars.entry(var_name).or_default();
428
429        for append in mutations.appends {
430            value.push_str(&append);
431        }
432
433        if !mutations.prepends.is_empty() {
434            let mut prefix = String::new();
435            for prepend in mutations.prepends {
436                prefix.push_str(&prepend);
437            }
438            value.insert_str(0, &prefix);
439        }
440
441        for remove in mutations.removes {
442            *value = remove_override_tokens(value, &remove);
443        }
444    }
445}
446
447fn remove_override_tokens(current: &str, remove: &str) -> String {
448    let removal_tokens: Vec<&str> = remove.split_whitespace().collect();
449    if removal_tokens.is_empty() {
450        return current.to_string();
451    }
452
453    current
454        .split_whitespace()
455        .filter(|token| !removal_tokens.contains(token))
456        .collect::<Vec<_>>()
457        .join(" ")
458}
459
460#[derive(Debug, Clone, Copy, PartialEq)]
461enum AssignOp {
462    Set,
463    Immediate,
464    WeakSet,
465    WeakDefault,
466    Append,
467    Prepend,
468    AppendNoSpace,
469    PrependNoSpace,
470    OverrideAppend,
471    OverridePrepend,
472    OverrideRemove,
473}
474
475fn parse_assignment(line: &str) -> Option<(String, String, AssignOp)> {
476    let operators: &[(&str, AssignOp)] = &[
477        ("??=", AssignOp::WeakDefault),
478        ("?=", AssignOp::WeakSet),
479        (":=", AssignOp::Immediate),
480        ("+=", AssignOp::Append),
481        ("=+", AssignOp::Prepend),
482        (".=", AssignOp::AppendNoSpace),
483        ("=.", AssignOp::PrependNoSpace),
484        ("=", AssignOp::Set),
485    ];
486
487    for (op_str, op) in operators {
488        if let Some(pos) = line.find(op_str) {
489            let raw_var_name = line[..pos].trim();
490            if raw_var_name.is_empty() || !is_valid_var_name(raw_var_name) {
491                continue;
492            }
493
494            let (var_name, op) = parse_override_var_name(raw_var_name)
495                .unwrap_or_else(|| (raw_var_name.to_string(), *op));
496            let value = line[pos + op_str.len()..].trim().to_string();
497
498            return Some((var_name, value, op));
499        }
500    }
501
502    None
503}
504
505fn parse_override_var_name(var_name: &str) -> Option<(String, AssignOp)> {
506    let colon_segments: Vec<&str> = var_name.split(':').collect();
507    if colon_segments.len() > 1 {
508        for (index, segment) in colon_segments.iter().enumerate() {
509            let op = match *segment {
510                "append" => AssignOp::OverrideAppend,
511                "prepend" => AssignOp::OverridePrepend,
512                "remove" => AssignOp::OverrideRemove,
513                _ => continue,
514            };
515
516            let canonical = colon_segments
517                .iter()
518                .enumerate()
519                .filter_map(|(current, segment)| (current != index).then_some(*segment))
520                .collect::<Vec<_>>()
521                .join(":");
522
523            return Some((canonical, op));
524        }
525    }
526
527    for (suffix, op) in [
528        ("_append", AssignOp::OverrideAppend),
529        ("_prepend", AssignOp::OverridePrepend),
530        ("_remove", AssignOp::OverrideRemove),
531    ] {
532        if let Some(base) = var_name.strip_suffix(suffix) {
533            return Some((base.to_string(), op));
534        }
535    }
536
537    None
538}
539
540fn is_valid_var_name(s: &str) -> bool {
541    let base = s.split([':', '[']).next().unwrap_or(s);
542    !base.is_empty()
543        && base
544            .chars()
545            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$' || c == '{' || c == '}')
546}
547
548fn strip_quotes(s: &str) -> String {
549    let trimmed = s.trim();
550    if trimmed.len() >= 2
551        && ((trimmed.starts_with('"') && trimmed.ends_with('"'))
552            || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
553    {
554        trimmed[1..trimmed.len() - 1].to_string()
555    } else {
556        trimmed.to_string()
557    }
558}
559
560fn extract_inherits(content: &str) -> Vec<String> {
561    let mut inherits = Vec::new();
562    for line in content.lines() {
563        let trimmed = line.trim();
564        if let Some(rest) = trimmed.strip_prefix("inherit ") {
565            for class in rest.split_whitespace() {
566                if !class.starts_with('#') {
567                    inherits.push(class.to_string());
568                } else {
569                    break;
570                }
571            }
572        }
573    }
574    inherits
575}
576
577fn is_rdepends_key(key: &str) -> bool {
578    key == "RDEPENDS"
579        || key.starts_with("RDEPENDS:")
580        || key.starts_with("RDEPENDS_")
581        || key.starts_with("RDEPENDS[")
582}
583
584#[derive(Debug, Clone, PartialEq, Eq)]
585struct ParsedDependency {
586    name: String,
587    requirement: Option<String>,
588}
589
590#[derive(Debug, Clone, PartialEq, Eq)]
591struct SrcUriEntry {
592    uri: String,
593    name: Option<String>,
594    sha1sum: Option<String>,
595    md5sum: Option<String>,
596    sha256sum: Option<String>,
597    sha512sum: Option<String>,
598}
599
600fn parse_dependency_list(value: &str) -> Vec<ParsedDependency> {
601    let cleaned_value = strip_bitbake_expansions(value);
602    let tokens: Vec<&str> = cleaned_value.split_whitespace().collect();
603    let mut dependencies = Vec::new();
604    let mut index = 0;
605
606    while index < tokens.len() {
607        let token = tokens[index];
608        let Some(name) = normalize_dependency_name_token(token) else {
609            index += 1;
610            continue;
611        };
612
613        let mut requirement = None;
614
615        if tokens
616            .get(index + 1)
617            .is_some_and(|next| next.starts_with('('))
618        {
619            let mut pieces = Vec::new();
620            index += 1;
621
622            while index < tokens.len() {
623                let piece = tokens[index];
624                pieces.push(piece);
625                if piece.ends_with(')') {
626                    break;
627                }
628                index += 1;
629            }
630
631            let joined = pieces.join(" ");
632            let cleaned = joined
633                .trim()
634                .trim_start_matches('(')
635                .trim_end_matches(')')
636                .trim()
637                .to_string();
638            if !cleaned.is_empty() {
639                requirement = Some(cleaned);
640            }
641        }
642
643        dependencies.push(ParsedDependency { name, requirement });
644        index += 1;
645    }
646
647    dependencies
648}
649
650fn strip_bitbake_expansions(value: &str) -> String {
651    let mut result = String::with_capacity(value.len());
652    let chars: Vec<char> = value.chars().collect();
653    let mut index = 0;
654
655    while index < chars.len() {
656        if chars[index] == '$' && chars.get(index + 1) == Some(&'{') {
657            index += 2;
658            let mut depth = 1;
659            while index < chars.len() && depth > 0 {
660                match chars[index] {
661                    '{' => depth += 1,
662                    '}' => depth -= 1,
663                    _ => {}
664                }
665                index += 1;
666            }
667            result.push(' ');
668            continue;
669        }
670
671        result.push(chars[index]);
672        index += 1;
673    }
674
675    result
676}
677
678fn normalize_dependency_name_token(token: &str) -> Option<String> {
679    let trimmed = token.trim_matches(|c| matches!(c, '"' | '\'' | ','));
680    if trimmed.is_empty() || trimmed.contains('$') {
681        return None;
682    }
683
684    let first = trimmed.chars().next()?;
685    if !first.is_ascii_alphanumeric() {
686        return None;
687    }
688
689    if trimmed
690        .chars()
691        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.' | '/'))
692    {
693        Some(trimmed.to_string())
694    } else {
695        None
696    }
697}
698
699fn extract_src_uri_data(src_uri: &str) -> (Vec<SrcUriEntry>, Vec<FileReference>) {
700    let mut remote_entries = Vec::new();
701    let mut local_references = Vec::new();
702
703    for entry in src_uri.split_whitespace() {
704        if entry.is_empty() {
705            continue;
706        }
707
708        let mut parts = entry.split(';');
709        let base = parts.next().unwrap_or(entry);
710
711        let mut remote_entry = SrcUriEntry {
712            uri: truncate_field(base.to_string()),
713            name: None,
714            sha1sum: None,
715            md5sum: None,
716            sha256sum: None,
717            sha512sum: None,
718        };
719
720        for parameter in parts {
721            let Some((key, value)) = parameter.split_once('=') else {
722                continue;
723            };
724
725            match key {
726                "name" => remote_entry.name = Some(value.to_string()),
727                "sha1sum" => remote_entry.sha1sum = Some(value.to_string()),
728                "md5sum" => remote_entry.md5sum = Some(value.to_string()),
729                "sha256sum" => remote_entry.sha256sum = Some(value.to_string()),
730                "sha512sum" => remote_entry.sha512sum = Some(value.to_string()),
731                _ => {}
732            }
733        }
734
735        if let Some(path) = base.strip_prefix("file://") {
736            if !path.is_empty() {
737                local_references.push(file_reference_from_path(path, "SRC_URI"));
738            }
739            continue;
740        }
741
742        remote_entries.push(remote_entry);
743    }
744
745    (remote_entries, local_references)
746}
747
748fn extract_lic_files_chksum_references(value: &str) -> Vec<FileReference> {
749    let mut references = Vec::new();
750
751    for entry in value.split_whitespace() {
752        let Some(path) = entry
753            .split(';')
754            .next()
755            .and_then(|item| item.strip_prefix("file://"))
756        else {
757            continue;
758        };
759
760        if path.is_empty() {
761            continue;
762        }
763
764        let mut reference = file_reference_from_path(path, "LIC_FILES_CHKSUM");
765        let mut extra_data = reference.extra_data.take().unwrap_or_default();
766
767        for parameter in entry.split(';').skip(1) {
768            let Some((key, raw_value)) = parameter.split_once('=') else {
769                continue;
770            };
771
772            match key {
773                "md5" => {
774                    reference.md5 = Md5Digest::from_hex(raw_value).ok();
775                }
776                _ => {
777                    extra_data.insert(key.to_string(), Value::String(raw_value.to_string()));
778                }
779            }
780        }
781
782        reference.extra_data = (!extra_data.is_empty()).then_some(extra_data);
783        references.push(reference);
784    }
785
786    references
787}
788
789fn file_reference_from_path(path: &str, source_variable: &str) -> FileReference {
790    let mut reference = FileReference::from_path(truncate_field(path.to_string()));
791    let mut extra_data = HashMap::new();
792    extra_data.insert(
793        "source_variable".to_string(),
794        Value::String(source_variable.to_string()),
795    );
796    reference.extra_data = Some(extra_data);
797    reference
798}
799
800fn merge_file_references(target: &mut Vec<FileReference>, additions: Vec<FileReference>) {
801    for addition in additions {
802        if let Some(existing) = target
803            .iter_mut()
804            .find(|reference| reference.path == addition.path)
805        {
806            if existing.md5.is_none() {
807                existing.md5 = addition.md5;
808            }
809            if existing.sha1.is_none() {
810                existing.sha1 = addition.sha1;
811            }
812            if existing.sha256.is_none() {
813                existing.sha256 = addition.sha256;
814            }
815            if existing.sha512.is_none() {
816                existing.sha512 = addition.sha512;
817            }
818            if existing.extra_data.is_none() {
819                existing.extra_data = addition.extra_data;
820            } else if let (Some(existing_extra), Some(addition_extra)) =
821                (&mut existing.extra_data, addition.extra_data)
822            {
823                existing_extra.extend(addition_extra);
824            }
825            continue;
826        }
827
828        target.push(addition);
829    }
830}
831
832fn normalize_bitbake_license(license: &str) -> String {
833    let mut result = String::with_capacity(license.len());
834    let mut chars = license.chars().peekable();
835    while let Some(ch) = chars.next() {
836        if ch == '&' {
837            let trimmed = result.trim_end();
838            result.truncate(trimmed.len());
839            result.push_str(" AND ");
840            while chars.peek() == Some(&' ') {
841                chars.next();
842            }
843        } else if ch == '|' {
844            let trimmed = result.trim_end();
845            result.truncate(trimmed.len());
846            result.push_str(" OR ");
847            while chars.peek() == Some(&' ') {
848                chars.next();
849            }
850        } else {
851            result.push(ch);
852        }
853    }
854    result
855}
856
857fn build_package_purl(name: &str, version: Option<&str>) -> Option<String> {
858    let mut purl = PackageUrl::new(PackageType::Bitbake.as_str(), name).ok()?;
859    if let Some(v) = version {
860        purl.with_version(v).ok()?;
861    }
862    Some(truncate_field(purl.to_string()))
863}
864
865fn build_dependency_purl(name: &str) -> Option<String> {
866    PackageUrl::new(PackageType::Bitbake.as_str(), name)
867        .ok()
868        .map(|purl| truncate_field(purl.to_string()))
869}