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