//! Quirks module for handling package-specific workarounds //! //! This module provides functionality to read quirks from a YAML file //! and apply them during pull and deb operations. use crate::data::embed_data; use crate::debian::deps::{Deps, ParseOpts, PkgRelation}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Build-dependency resolution rules for a package /// /// Applied after the declared Build-* fields are parsed and reduced, /// before the resolver derives anything from them. Dependency strings /// use the full dependency grammar: `name[:arch] [(op version)] /// [arches] `. #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct DependencyQuirks { /// Declared dependency name -> dependency string to resolve in its /// place. The replacement is parsed fresh and replaces the declared /// dependency wholesale (qualifier, version, restrictions). #[serde(default)] pub replace: HashMap, /// Dependencies to resolve as if the control declared them. #[serde(default)] pub inject: Vec, /// Declared dependency names to ignore. #[serde(default)] pub drop: Vec, } /// Quirks configuration for a specific operation (pull or deb) #[derive(Debug, Clone, Deserialize, Serialize)] pub struct OperationQuirks { /// Series the entry applies to. An empty list applies to every /// series; packaging workarounds should carry the series they were /// verified against, so they can be dropped once the upstream /// packaging catches up. #[serde(default)] pub series: Vec, /// Build-dependency resolution rules. #[serde(default)] pub dependencies: Option, /// Additional parameters for the operation #[serde(default)] pub parameters: HashMap, /// Custom package directories to try when looking for the package source /// This is useful for packages that don't follow the standard naming conventions /// like linux packages that use directories like "linux-main" or other custom names #[serde(default)] pub package_directory: Vec, } /// Quirks for a specific package /// /// `pull` and `deb` hold one entry per scope: an operation can carry /// several entries with different `series` lists; every matching entry /// applies, in file order. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct PackageQuirks { /// Quirks to apply during pull operation #[serde(default)] pub pull: Vec, /// Quirks to apply during deb operation #[serde(default)] pub deb: Vec, } /// Top-level quirks configuration #[derive(Debug, Clone, Deserialize, Serialize)] pub struct QuirksConfig { /// Map of package names to their quirks pub quirks: HashMap, } embed_data! { static ref QUIRKS_DATA: QuirksConfig = "../data/quirks.yml" } /// Get quirks for a specific package /// /// # Arguments /// * `config` - The quirks configuration /// * `package` - The package name /// /// # Returns /// * `Option` - The quirks for the package, or None if not found pub fn get_package_quirks<'a>( config: &'a QuirksConfig, package: &str, ) -> Option<&'a PackageQuirks> { config.quirks.get(package) } /// Whether a quirks entry applies to `series`: an empty series filter /// matches every series, otherwise the series must be listed. fn entry_applies_to_series(quirks: &OperationQuirks, series: &str) -> bool { quirks.series.is_empty() || quirks.series.iter().any(|s| s == series) } /// Get the build-dependency resolution rules of a package for a series /// /// Every deb entry whose series list matches contributes its rules; the /// returned rules apply in file order. /// /// # Arguments /// * `package` - The package name /// * `series` - The distribution series (e.g. "resolute") /// /// # Returns /// * `Vec` - The matching rules, empty when the package /// has no deb entry or none applies to the series pub fn get_deb_dependency_quirks(package: &str, series: &str) -> Vec { let Some(quirks) = get_package_quirks(&QUIRKS_DATA, package) else { return Vec::new(); }; quirks .deb .iter() .filter(|deb| entry_applies_to_series(deb, series)) .filter_map(|deb| deb.dependencies.clone()) .collect() } /// Get package directories from quirks configuration /// /// This function returns the list of custom package directories to try /// when looking for the package source directory: every matching deb /// entry contributes its directories, falling back to the pull entries /// when no deb entry carries any. /// /// # Arguments /// * `package` - The package name /// * `series` - The distribution series (e.g. "resolute") /// /// # Returns /// * `Vec` - List of package directories to try, or empty vector if none pub fn get_package_directories(package: &str, series: &str) -> Vec { let Some(quirks) = get_package_quirks(&QUIRKS_DATA, package) else { return Vec::new(); }; let mut directories = Vec::new(); for deb in quirks .deb .iter() .filter(|q| entry_applies_to_series(q, series)) { directories.extend(deb.package_directory.iter().cloned()); } if directories.is_empty() { for pull in quirks .pull .iter() .filter(|q| entry_applies_to_series(q, series)) { directories.extend(pull.package_directory.iter().cloned()); } } directories } /// Apply the dependency quirks of `package` in `series` to parsed /// build-dependency clauses /// /// Rules apply in order — drop, replace, inject. `replace` matches by /// declared name wherever the dependency appears; rule names that match /// nothing are warned about, so stale quirks surface once the upstream /// packaging is fixed. pub fn apply_dependency_quirks( package: &str, series: &str, clauses: &mut Vec>, opts: &ParseOpts, ) -> Result<(), String> { for deps in get_deb_dependency_quirks(package, series) { apply_rules(clauses, &deps, opts)?; } Ok(()) } /// Apply one set of dependency rules to parsed clauses. fn apply_rules( clauses: &mut Vec>, deps: &DependencyQuirks, opts: &ParseOpts, ) -> Result<(), String> { for name in &deps.drop { let hits = clauses .iter() .flatten() .filter(|rel| &rel.package == name) .count(); if hits == 0 { log::warn!("dependency quirk: 'drop {name}' matched nothing"); } } if !deps.drop.is_empty() { for clause in clauses.iter_mut() { clause.retain(|rel| !deps.drop.iter().any(|name| name == &rel.package)); } clauses.retain(|clause| !clause.is_empty()); } for (declared, replacement) in &deps.replace { let mut hits = 0; for clause in clauses.iter_mut() { for rel in clause.iter_mut() { if rel.package == *declared { *rel = crate::debian::deps::parse_simple(replacement, true) .map_err(|e| format!("invalid replacement '{replacement}': {e}"))?; hits += 1; } } } if hits == 0 { log::warn!("dependency quirk: 'replace {declared}' matched nothing"); } } for injected in &deps.inject { let parsed = Deps::parse(injected, opts)?; clauses.extend(parsed.clauses().map(<[PkgRelation]>::to_vec)); } Ok(()) } #[cfg(test)] mod tests { use super::*; fn parse(s: &str) -> PkgRelation { crate::debian::deps::parse_simple(s, true).unwrap() } fn opts() -> ParseOpts { ParseOpts { host_arch: "riscv64".into(), build_arch: "amd64".into(), build_profiles: vec!["cross".into()], reduce_restrictions: true, union: false, build_dep: true, } } #[test] fn test_unknown_package_has_no_quirks() { // A package absent from quirks.yml has no dependency rules nor // custom directories, and must not panic assert!(get_deb_dependency_quirks("not-in-quirks", "resolute").is_empty()); assert!(get_package_directories("not-in-quirks", "resolute").is_empty()); } /// The linux dependency quirks are scoped to the series they were /// verified against. #[test] fn linux_dependency_quirks_are_series_scoped() { for package in ["linux", "linux-riscv"] { let rules = get_deb_dependency_quirks(package, "resolute"); assert_eq!(rules.len(), 2, "the resolute entries apply"); assert_eq!( rules[0].replace.get("llvm-21-dev").map(String::as_str), Some("llvm-21-dev:native ") ); // Only the stonking entry exists there: the llvm-21-dev // replacement above is a resolute-only packaging state. let rules = get_deb_dependency_quirks(package, "stonking"); assert_eq!(rules.len(), 1); assert!(rules[0].replace.is_empty()); assert!(get_deb_dependency_quirks(package, "noble").is_empty()); } } /// The kernels of the series whose control dropped the Debian-style /// `:native` qualifiers on the host-tool libraries inject the /// build-architecture variants: the dpkg cross rules resolve the /// unqualified Multi-Arch: same names against the host architecture /// only, leaving nothing for the kernel's host-side tools to link. #[test] fn linux_injects_native_host_tool_libraries_for_cross() { for series in ["resolute", "stonking"] { let mut clauses = vec![vec![parse("libelf-dev ")]]; crate::quirks::apply_dependency_quirks("linux", series, &mut clauses, &opts()).unwrap(); let injected: Vec<&PkgRelation> = clauses[1..] .iter() .flatten() .filter(|rel| { ["libelf-dev", "libdw-dev", "libssl-dev"].contains(&rel.package.as_str()) }) .collect(); assert_eq!( injected.len(), 3, "one :native clause per host-tool library" ); assert!( injected .iter() .all(|rel| rel.arch_qualifier.as_deref() == Some("native")) ); // The declared dependency itself is untouched: the host // (target) variant still installs for the checker. assert_eq!(clauses[0][0].arch_qualifier, None); } } /// Other kernel series keep their declared dependencies untouched. #[test] fn noble_linux_dependencies_are_not_rewritten() { let mut clauses = vec![vec![parse("libelf-dev")]]; crate::quirks::apply_dependency_quirks("linux", "noble", &mut clauses, &opts()).unwrap(); assert_eq!(clauses.len(), 1); assert_eq!(clauses[0][0].arch_qualifier, None); } /// `replace` rewrites exactly the dependencies whose declared name /// matches, wholesale: the replacement carries its own qualifier and /// restrictions. #[test] fn replace_rewrites_matching_names_only() { let mut clauses = vec![vec![ parse("llvm-21-dev "), parse("clang-21:native"), ]]; let deps = DependencyQuirks { replace: HashMap::from([( "llvm-21-dev".to_string(), "llvm-21-dev:native ".to_string(), )]), ..Default::default() }; apply_rules(&mut clauses, &deps, &opts()).unwrap(); let rewritten = &clauses[0][0]; assert_eq!(rewritten.arch_qualifier.as_deref(), Some("native")); assert_eq!(rewritten.restrictions.len(), 1); assert_eq!(clauses[0][1].arch_qualifier.as_deref(), Some("native")); } /// `drop` removes named dependencies (empty clauses disappear) and /// `inject` appends dependencies resolved like declared ones. #[test] fn drop_and_inject() { let mut clauses = vec![vec![parse("broken-dep"), parse("keep-me")]]; let deps = DependencyQuirks { inject: vec!["injected-dep:any".to_string()], drop: vec!["broken-dep".to_string()], ..Default::default() }; apply_rules(&mut clauses, &deps, &opts()).unwrap(); let names: Vec<&str> = clauses .iter() .flatten() .map(|rel| rel.package.as_str()) .collect(); assert_eq!(names, ["keep-me", "injected-dep"]); } /// A replacement that does not parse is a quirk configuration error, /// not a silent no-op. #[test] fn invalid_replacement_is_an_error() { let mut clauses = vec![vec![parse("llvm-21-dev")]]; let deps = DependencyQuirks { replace: HashMap::from([("llvm-21-dev".to_string(), "not@@valid".to_string())]), ..Default::default() }; assert!(apply_rules(&mut clauses, &deps, &opts()).is_err()); } }