/
githubmirror
/
rpm-ostree
Обзор
Документация
Войти
/
githubmirror
/
rpm-ostree
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
rust/src/container.rs
1 170 строк
43 KB
ckyrouac
rechunker: Parse specific components into (path, checksum)
26 авг 2025, 22:06
26 авг 2025, 22:06
4544bf9
Код
Авторство
О чём код?
//! CLI exposing `ostree-rs-ext container` // SPDX-License-Identifier: Apache-2.0 OR MIT use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::ffi::CStr; use std::fmt::Debug; use std::fs::File; use std::io::BufReader; use std::num::NonZeroU32; use std::process::Command; use std::rc::Rc; use anyhow::{Context, Result}; use camino::{Utf8Path, Utf8PathBuf}; use cap_std::fs::Dir; use cap_std_ext::cap_std; use cap_std_ext::prelude::*; use chrono::prelude::*; use clap::Parser; use fn_error_context::context; use ostree::glib; use ostree_ext::chunking::ObjectMetaSized; use ostree_ext::container::{Config, ExportOpts, ImageReference}; use ostree_ext::containers_image_proxy; use ostree_ext::objectsource::{ ContentID, ObjectMeta, ObjectMetaMap, ObjectMetaSet, ObjectSourceMeta, }; use ostree_ext::oci_spec::image::{Arch, Os, PlatformBuilder}; use ostree_ext::prelude::*; use ostree_ext::{gio, oci_spec, ostree}; use crate::cmdutils::CommandRunExt; use crate::cxxrsutil::FFIGObjectReWrap; use crate::fsutil::{self, FileHelpers, ResolvedOstreePaths}; use crate::progress::progress_task; use crate::CxxResult; const COMPONENT_XATTR: &CStr = c"user.component"; #[derive(Debug, Parser)] struct ContainerEncapsulateOpts { #[clap(long)] #[clap(value_parser)] repo: Utf8PathBuf, /// OSTree branch name or checksum ostree_ref: String, /// Image reference, e.g. registry:quay.io/exampleos/exampleos:latest #[clap(value_parser = ostree_ext::cli::parse_base_imgref)] imgref: ImageReference, /// Additional labels for the container #[clap(name = "label", long, short)] labels: Vec<String>, /// Path to container image configuration in JSON format. This is the `config` /// field of https://github.com/opencontainers/image-spec/blob/main/config.md #[clap(long)] image_config: Option<Utf8PathBuf>, /// Override the architecture. #[clap(long)] arch: Option<Arch>, /// Propagate an OSTree commit metadata key to container label #[clap(name = "copymeta", long)] copy_meta_keys: Vec<String>, /// Propagate an optionally-present OSTree commit metadata key to container label #[clap(name = "copymeta-opt", long)] copy_meta_opt_keys: Vec<String>, /// Corresponds to the Dockerfile `CMD` instruction. #[clap(long)] cmd: Option<Vec<String>>, /// Maximum number of container image layers #[clap(long)] max_layers: Option<NonZeroU32>, /// The encapsulated container format version; must be 1 or 2. #[clap(long, default_value = "1")] format_version: u32, #[clap(long)] /// Output content metadata as JSON write_contentmeta_json: Option<Utf8PathBuf>, /// Compare OCI layers of current build with another(imgref) #[clap(name = "compare-with-build", long)] compare_with_build: Option<String>, /// Prevent a change in packing structure by taking a previous build metadata (oci config and /// manifest) #[clap(long)] previous_build_manifest: Option<Utf8PathBuf>, } #[derive(Debug)] struct MappingBuilder { /// Maps from package ID to metadata packagemeta: ObjectMetaSet, /// Maps from component ID to metadata componentmeta: ObjectMetaSet, /// Component IDs encountered during filesystem walk for efficient lookup component_ids: HashSet<String>, /// Maps from object checksum to absolute filesystem path checksum_paths: BTreeMap<String, BTreeSet<Utf8PathBuf>>, /// Maps from absolute filesystem path to the package IDs that /// provide it path_packages: HashMap<Utf8PathBuf, BTreeSet<ContentID>>, /// Maps from absolute filesystem path to component IDs (for exclusive layers) path_components: HashMap<Utf8PathBuf, BTreeSet<ContentID>>, unpackaged_id: ContentID, /// Files that were processed before the global tree walk skip: HashSet<Utf8PathBuf>, /// Size according to RPM database rpmsize: u64, } impl MappingBuilder { /// For now, we stick everything that isn't a package inside a single "unpackaged" state. /// In the future though if we support e.g. containers in /usr/share/containers or the /// like, this will need to change. const UNPACKAGED_ID: &'static str = "rpmostree-unpackaged-content"; fn duplicate_objects(&self) -> impl Iterator<Item = (&String, &BTreeSet<Utf8PathBuf>)> { self.checksum_paths .iter() .filter(|(_, paths)| paths.len() > 1) } fn multiple_owners(&self) -> impl Iterator<Item = (&Utf8PathBuf, &BTreeSet<ContentID>)> { self.path_packages.iter().filter(|(_, pkgs)| pkgs.len() > 1) } } // loop over checksum_paths (this is the entire list of files) // check if there is a mapping for the file in the "explicit" mapping // check if there is a mapping for the file in the package mapping // otherwise put it in the unpackaged bucket impl MappingBuilder { fn create_meta(&self) -> (ObjectMeta, BTreeMap<ContentID, Vec<(Utf8PathBuf, String)>>) { let mut package_content = ObjectMetaMap::default(); // Build map with content_id -> Vec<(checksum, path)> for components let mut component_content_map = BTreeMap::new(); for (checksum, paths) in &self.checksum_paths { for path in paths { if let Some(component_ids) = self.path_components.get(path) { if let Some(content_id) = component_ids.first() { component_content_map .entry(content_id.clone()) .or_insert_with(Vec::new) .push((path.clone(), checksum.clone())); } } else if let Some(package_ids) = self.path_packages.get(path) { if let Some(content_id) = package_ids.first() { package_content.insert(checksum.clone(), content_id.clone()); } } else { package_content.insert(checksum.clone(), self.unpackaged_id.clone()); } } } let package_meta = ObjectMeta { map: package_content, set: self.packagemeta.clone(), }; (package_meta, component_content_map) } } /// Walk over the whole filesystem, and generate mappings from content object checksums /// to the path that provides them. fn build_fs_mapping_recurse( path: &mut Utf8PathBuf, dir: &gio::File, state: &mut MappingBuilder, parent_component: Option<String>, ) -> Result<()> { let e = dir.enumerate_children( "standard::name,standard::type", gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, gio::Cancellable::NONE, )?; for child in e { let childi = child?; let name: Utf8PathBuf = childi.name().try_into()?; let child = dir.child(&name); path.push(&name); match childi.file_type() { gio::FileType::Regular | gio::FileType::SymbolicLink => { let child = child.downcast::<ostree::RepoFile>().unwrap(); // Remove the skipped path, since we can't hit it again. if state.skip.remove(Utf8Path::new(path)) { path.pop(); continue; } // Try to read user.component xattr to identify component-based chunks let file_component = get_user_component_xattr(&child)?; let effective_component = file_component.or_else(|| parent_component.clone()); if let Some(component_name) = effective_component { let component_id = Rc::from(component_name.clone()); // Track component ID for later processing state.component_ids.insert(component_name); // Associate this path with the component state .path_components .entry(path.clone()) .or_default() .insert(Rc::clone(&component_id)); }; // Ensure there's a checksum -> path entry. If it was previously // accounted for by a package or component, this is essentially a no-op. If not, // there'll be no corresponding path -> package entry, and the packaging // operation will treat the file as being "unpackaged". let checksum = child.checksum().to_string(); state .checksum_paths .entry(checksum) .or_default() .insert(path.clone()); } gio::FileType::Directory => { let child_repo_file = child.clone().downcast::<ostree::RepoFile>().unwrap(); // Check if this directory has its own user.component xattr let dir_component = get_user_component_xattr(&child_repo_file)?; let effective_component = dir_component.or_else(|| parent_component.clone()); // Recursively process the directory with the new parent component build_fs_mapping_recurse(path, &child, state, effective_component)?; } o => anyhow::bail!("Unhandled file type: {o:?}"), } path.pop(); } Ok(()) } fn gv_nevra_to_string(pkg: &glib::Variant) -> String { let name = pkg.child_value(0); let name = name.str().unwrap(); let epoch = pkg.child_value(1); let epoch = epoch.str().unwrap(); let version = pkg.child_value(2); let version = version.str().unwrap(); let release = pkg.child_value(3); let release = release.str().unwrap(); let arch = pkg.child_value(4); let arch = arch.str().unwrap(); if epoch == "0" { format!("{}-{}-{}.{}", name, version, release, arch) } else { format!("{}-{}:{}-{}.{}", name, epoch, version, release, arch) } } /// Read the user.component xattr from a file path, returning None if not present fn get_user_component_xattr(file: &ostree::RepoFile) -> std::io::Result<Option<String>> { let xattrs = match file.xattrs(gio::Cancellable::NONE) { Ok(xattrs) => xattrs, Err(_) => return Ok(None), // No xattrs available }; let n = xattrs.n_children(); for i in 0..n { let child = xattrs.child_value(i); let key = child.child_value(0); let key_bytes = key.data_as_bytes(); if key_bytes == COMPONENT_XATTR.to_bytes_with_nul() { let value = child.child_value(1); let value = value.data_as_bytes(); let value_str = String::from_utf8(value.to_vec()) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; return Ok(Some(value_str)); } } Ok(None) } async fn compare_builds(old_build: &str, new_build: &str) -> Result<()> { let proxy = containers_image_proxy::ImageProxy::new().await?; let oi_old = proxy.open_image(old_build).await?; let (_, manifest_old) = proxy.fetch_manifest(&oi_old).await?; let oi_now = proxy.open_image(new_build).await?; let (_, new_manifest) = proxy.fetch_manifest(&oi_now).await?; let diff = ostree_ext::container::ManifestDiff::new(&manifest_old, &new_manifest); diff.print(); Ok(()) } /// Like `ostree container encapsulate`, but uses chunks derived from package data. pub fn container_encapsulate(args: Vec<String>) -> CxxResult<()> { let args = args.iter().skip(1).map(|s| s.as_str()); let opt = ContainerEncapsulateOpts::parse_from(args); let repo = &ostree_ext::cli::parse_repo(&opt.repo)?; let (root, rev) = repo.read_commit(opt.ostree_ref.as_str(), gio::Cancellable::NONE)?; let cancellable = gio::Cancellable::new(); let pkglist: glib::Variant = { let r = crate::ffi::package_variant_list_for_commit( repo.reborrow_cxx(), rev.as_str(), cancellable.reborrow_cxx(), ) .context("Reading package variant list")?; unsafe { glib::translate::from_glib_full(r as *mut _) } }; // Open the RPM database for this commit. let q = crate::ffi::rpmts_for_commit(repo.reborrow_cxx(), rev.as_str()).context("Getting refts")?; let mut state = MappingBuilder { unpackaged_id: Rc::from(MappingBuilder::UNPACKAGED_ID), packagemeta: Default::default(), componentmeta: Default::default(), checksum_paths: Default::default(), path_packages: Default::default(), path_components: Default::default(), skip: Default::default(), component_ids: Default::default(), rpmsize: Default::default(), }; // Insert metadata for unpackaged content. state.packagemeta.insert(ObjectSourceMeta { identifier: Rc::clone(&state.unpackaged_id), name: Rc::clone(&state.unpackaged_id), srcid: Rc::clone(&state.unpackaged_id), // Assume that content in here changes frequently. change_time_offset: u32::MAX, change_frequency: u32::MAX, }); let mut lowest_change_time = None; let mut highest_change_time = None; let mut package_meta = HashMap::new(); if pkglist.n_children() == 0 { return Err("Failed to find any packages".to_owned().into()); } for pkg in pkglist.iter() { let name = pkg.child_value(0); let name = name.str().unwrap(); let arch = pkg.child_value(4); let arch = arch.str().unwrap(); let nevra = Rc::from(gv_nevra_to_string(&pkg).into_boxed_str()); let pkgmeta = q .package_meta(name, arch) .context("Querying package meta")?; let buildtime = pkgmeta.buildtime(); if let Some((lowid, lowtime)) = lowest_change_time.as_mut() { if *lowtime > buildtime { *lowid = Rc::clone(&nevra); *lowtime = buildtime; } } else { lowest_change_time = Some((Rc::clone(&nevra), pkgmeta.buildtime())) } if let Some(hightime) = highest_change_time.as_mut() { if *hightime < buildtime { *hightime = buildtime; } } else { highest_change_time = Some(pkgmeta.buildtime()) } state.rpmsize += pkgmeta.size(); package_meta.insert(nevra, pkgmeta); } // SAFETY: There must be at least one package; checked above. let (lowest_change_name, lowest_change_time) = lowest_change_time.expect("Failed to find any packages"); let highest_change_time = highest_change_time.expect("Failed to find any packages"); // Walk over the packages, and generate the `packagemeta` mapping, which is basically a subset of // package metadata abstracted for ostree. Note that right now, the package metadata includes // both a "unique identifer" and a "human readable name", but for rpm-ostree we're just making // those the same thing. for (nevra, pkgmeta) in package_meta.iter() { let buildtime = pkgmeta.buildtime(); let change_time_offset_secs: u32 = buildtime .checked_sub(lowest_change_time) .unwrap() .try_into() .unwrap(); // Convert to hours, because there's no strong use for caring about the relative difference of builds in terms // of minutes or seconds. let change_time_offset = change_time_offset_secs / (60 * 60); let changelogs = pkgmeta.changelogs(); // Ignore the updates to packages more than a year away from the latest built package as its // contribution becomes increasingly irrelevant to the likelihood of the package updating // in the future // TODO: Weighted Moving Averages (Weights decaying by year) to calculate the frequency let pruned_changelogs: Vec<&u64> = changelogs .iter() .filter(|e| { let curr_build = glib::DateTime::from_unix_utc(**e as i64).unwrap(); let highest_time_build = glib::DateTime::from_unix_utc(highest_change_time as i64).unwrap(); highest_time_build.difference(&curr_build).as_days() <= 365_i64 }) .collect(); state.packagemeta.insert(ObjectSourceMeta { identifier: Rc::clone(nevra), name: Rc::from(libdnf_sys::hy_split_nevra(nevra)?.name), srcid: Rc::from(pkgmeta.src_pkg()), change_time_offset, change_frequency: pruned_changelogs.len() as u32, }); } let kernel_dir = ostree_ext::bootabletree::find_kernel_dir(&root, gio::Cancellable::NONE)?; if let Some(kernel_dir) = kernel_dir { let kernel_ver: Utf8PathBuf = kernel_dir .basename() .unwrap() .try_into() .map_err(anyhow::Error::msg)?; let initramfs = kernel_dir.child("initramfs.img"); if initramfs.query_exists(gio::Cancellable::NONE) { let path: Utf8PathBuf = initramfs .path() .unwrap() .try_into() .map_err(anyhow::Error::msg)?; let initramfs = initramfs.downcast_ref::<ostree::RepoFile>().unwrap(); let checksum = initramfs.checksum(); let name = "initramfs".to_string(); let identifier = format!("{} (kernel {})", name, kernel_ver).into_boxed_str(); let identifier = Rc::from(identifier); state .checksum_paths .entry(checksum.to_string()) .or_default() .insert(path.clone()); state .path_packages .entry(path.clone()) .or_default() .insert(Rc::clone(&identifier)); state.packagemeta.insert(ObjectSourceMeta { identifier: Rc::clone(&identifier), name: Rc::from(name), srcid: Rc::clone(&identifier), change_time_offset: u32::MAX, change_frequency: u32::MAX, }); state.skip.insert(path); } } let rpmdb = root.resolve_relative_path(crate::composepost::RPMOSTREE_RPMDB_LOCATION); if rpmdb.query_exists(gio::Cancellable::NONE) { // TODO add mapping for rpmdb } progress_task("Building package mapping", || { // Walk each package, adding mappings for each of the files it provides let mut dir_cache: HashMap<Utf8PathBuf, ResolvedOstreePaths> = HashMap::new(); for (nevra, pkgmeta) in package_meta.iter() { for path in pkgmeta.provided_paths()? { let path = Utf8PathBuf::from(path); // Resolve the path to its ostree file if let Some(ostree_paths) = fsutil::resolve_ostree_paths( &path, root.downcast_ref::<ostree::RepoFile>().unwrap(), &mut dir_cache, ) { if ostree_paths.path.is_regular() || ostree_paths.path.is_symlink() { let real_path = Utf8PathBuf::from_path_buf(ostree_paths.path.peek_path().unwrap()) .unwrap(); let checksum = ostree_paths.path.checksum().to_string(); state .checksum_paths .entry(checksum) .or_default() .insert(real_path.clone()); state .path_packages .entry(real_path) .or_default() .insert(Rc::clone(nevra)); } } } } // Then, walk the file system marking any remainders as unpackaged build_fs_mapping_recurse(&mut Utf8PathBuf::from("/"), &root, &mut state, None) })?; // Now that we've walked the filesystem, process component metadata for component_name in state.component_ids.iter() { let component_id = Rc::from(component_name.clone()); let component_srcid = Rc::from(format!("component:{}", component_name)); state.componentmeta.insert(ObjectSourceMeta { identifier: component_id, name: Rc::from(component_name.clone()), srcid: component_srcid, // Assume component content changes frequently change_time_offset: u32::MAX, change_frequency: u32::MAX, }); } let src_pkgs: HashSet<_> = state.packagemeta.iter().map(|p| &p.srcid).collect(); // Print out information about what we found println!( "{} objects in {} packages ({} source)", state.checksum_paths.len(), state.packagemeta.len(), src_pkgs.len(), ); // Print component information if !state.componentmeta.is_empty() { println!("Found {} user component(s):", state.componentmeta.len()); for component_meta in &state.componentmeta { // Count files belonging to this component let component_files = state .path_components .values() .filter(|ids| ids.contains(&component_meta.identifier)) .count(); println!(" - {} ({} files)", component_meta.name, component_files); } } println!("rpm size: {}", state.rpmsize); println!( "Earliest changed package: {} at {}", lowest_change_name, Utc.timestamp_opt(lowest_change_time.try_into().unwrap(), 0) .unwrap() ); println!("Duplicates: {}", state.duplicate_objects().count()); println!("Multiple owners: {}", state.multiple_owners().count()); // Create separate ObjectMeta for packages and exclusive components let (package_meta, component_content_map) = state.create_meta(); // Generate sized versions let package_meta_sized = ObjectMetaSized::compute_sizes(repo, package_meta)?; if let Some(v) = opt.write_contentmeta_json { let v = v.strip_prefix("/").unwrap_or(&v); let root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?; root.atomic_replace_with(v, |w| { serde_json::to_writer(w, &package_meta_sized.sizes).map_err(anyhow::Error::msg) })?; } // TODO: Put this in a public API in ostree-rs-ext? let labels = opt .labels .into_iter() .map(|l| { let (k, v) = l .split_once('=') .ok_or_else(|| anyhow::anyhow!("Missing '=' in label {}", l))?; Ok((k.to_string(), v.to_string())) }) .collect::<Result<_>>()?; let package_structure = opt .previous_build_manifest .as_ref() .map(|p| { oci_spec::image::ImageManifest::from_file(p) .map_err(|e| anyhow::anyhow!("Failed to read previous manifest {p}: {e}")) }) .transpose()?; // Default to copying the input hash to support cheap change detection let copy_meta_opt_keys = opt .copy_meta_opt_keys .into_iter() .chain(std::iter::once("rpmostree.inputhash".to_owned())) .collect(); let config = Config { labels: Some(labels), cmd: opt.cmd, }; let mut opts = ExportOpts::default(); opts.copy_meta_keys = opt.copy_meta_keys; opts.copy_meta_opt_keys = copy_meta_opt_keys; opts.max_layers = opt.max_layers; opts.prior_build = package_structure.as_ref(); opts.package_contentmeta = Some(&package_meta_sized); opts.specific_contentmeta = Some(&component_content_map); if let Some(config_path) = opt.image_config.as_deref() { let config = serde_json::from_reader(File::open(config_path).map(BufReader::new)?) .map_err(anyhow::Error::msg)?; opts.container_config = Some(config); } // If an architecture was provided, then generate a new Platform (using the host OS type) // but override with that architecture. if let Some(arch) = opt.arch.as_ref() { let platform = PlatformBuilder::default() .architecture(arch.clone()) .os(Os::default()) .build() .unwrap(); opts.platform = Some(platform); } if opt.format_version >= 2 { opts.tar_create_parent_dirs = true; } let handle = tokio::runtime::Handle::current(); println!("Generating container image"); let digest = handle.block_on(async { ostree_ext::container::encapsulate(repo, rev.as_str(), &config, Some(opts), &opt.imgref) .await .context("Encapsulating") })?; if let Some(compare_with_build) = opt.compare_with_build.as_ref() { progress_task("Comparing Builds", || { handle.block_on(async { compare_builds(compare_with_build, &format!("{}", &opt.imgref)).await }) })?; }; println!("Pushed digest: {}", digest); Ok(()) } #[derive(clap::Parser)] struct UpdateFromRunningOpts { /// Path to target system root #[clap(value_parser)] target_root: Utf8PathBuf, #[clap(long)] /// Reboot after performing operation reboot: bool, } // This reimplements https://github.com/ostreedev/ostree/pull/2691 basically #[context("Finding encapsulated commits")] fn find_encapsulated_commits(repo: &Utf8Path) -> Result<Vec<String>> { let objects = Dir::open_ambient_dir(repo.join("objects"), cap_std::ambient_authority())?; let mut r = Vec::new(); for entry in objects.entries()? { let entry = entry?; let etype = entry.file_type()?; if !etype.is_dir() { continue; } let name = entry.file_name(); let name = if let Some(n) = name.to_str() { n } else { continue; }; let subd = entry.open_dir()?; for entry in subd.entries()? { let entry = entry?; let etype = entry.file_type()?; if !etype.is_file() { continue; } let subname = entry.file_name(); let subname = if let Some(n) = subname.to_str() { Utf8Path::new(n) } else { continue; }; if let (Some(stem), Some("commit")) = (subname.file_stem(), subname.extension()) { r.push(format!("{name}{stem}")); } } } Ok(r) } /// The implementation of `rpm-ostree ex deploy-from-self`, which writes /// the container ostree commit to the host and deploys it, optionally rebooting. pub(crate) fn deploy_from_self_entrypoint(args: Vec<String>) -> CxxResult<()> { use nix::sys::statvfs; let cancellable = gio::Cancellable::NONE; let opts = UpdateFromRunningOpts::parse_from(args); let sysroot = opts.target_root.join("sysroot"); if statvfs::statvfs(sysroot.as_std_path())? .flags() .contains(statvfs::FsFlags::ST_RDONLY) { Command::new("mount") .args(["-o", "remount,rw", sysroot.as_str()]) .run()?; } let src_repo_path = Utf8Path::new("/ostree/repo"); // Just verify it can be opened for now...in the future ideally we'll use https://github.com/ostreedev/ostree/pull/2701 let src_repo = ostree::Repo::open_at(libc::AT_FDCWD, src_repo_path.as_str(), cancellable)?; drop(src_repo); let encapsulated_commits = find_encapsulated_commits(src_repo_path)?; let commit = match encapsulated_commits.as_slice() { [] => { return Err("No encapsulated commit found in container" .to_string() .into()) } [c] => { ostree::validate_checksum_string(c)?; c.as_str() } o => return Err(format!("Found {} commit objects, expected just one", o.len()).into()), }; let target_repo = sysroot.join("ostree/repo"); let target_repo = ostree::Repo::open_at(libc::AT_FDCWD, target_repo.as_str(), cancellable)?; { let flags = ostree::RepoPullFlags::MIRROR; let opts = glib::VariantDict::new(None); let refs = [commit]; opts.insert("refs", &refs[..]); opts.insert("flags", flags.bits() as i32); let options = opts.to_variant(); target_repo .pull_with_options( &format!("file://{src_repo_path}"), &options, None, cancellable, ) .context("Pulling from embedded repo")?; } println!("Imported: {commit}"); Command::new("chroot") .args([opts.target_root.as_str(), "rpm-ostree", "rebase", commit]) .args(opts.reboot.then_some("--reboot")) .run()?; Ok(()) } #[cfg(test)] mod tests { use super::*; use std::collections::{BTreeSet, HashMap, HashSet}; #[test] fn test_mapping_builder_create_package_meta() { let mut builder = MappingBuilder { unpackaged_id: Rc::from("unpackaged"), packagemeta: ObjectMetaSet::new(), componentmeta: ObjectMetaSet::new(), checksum_paths: BTreeMap::new(), path_packages: HashMap::new(), path_components: HashMap::new(), skip: HashSet::new(), component_ids: HashSet::new(), rpmsize: 0, }; // Add a package let pkg_id = Rc::from("test-package"); builder.packagemeta.insert(ObjectSourceMeta { identifier: Rc::clone(&pkg_id), name: Rc::from("Test Package"), srcid: Rc::from("test-package-1.0-1.fc39"), change_time_offset: 100, change_frequency: 50, }); // Add a component let comp_id = Rc::from("test-component"); builder.componentmeta.insert(ObjectSourceMeta { identifier: Rc::clone(&comp_id), name: Rc::from("Test Component"), srcid: Rc::from("component:test-component"), change_time_offset: 200, change_frequency: 100, }); // Add paths and checksums let path1 = Utf8PathBuf::from("/usr/bin/test"); let path2 = Utf8PathBuf::from("/usr/share/component-file"); let checksum1 = "abc123".to_string(); let checksum2 = "def456".to_string(); // Associate path1 with package builder.path_packages.insert(path1.clone(), { let mut set = BTreeSet::new(); set.insert(Rc::clone(&pkg_id)); set }); // Associate path2 with component builder.path_components.insert(path2.clone(), { let mut set = BTreeSet::new(); set.insert(Rc::clone(&comp_id)); set }); // Add checksums builder.checksum_paths.insert(checksum1.clone(), { let mut set = BTreeSet::new(); set.insert(path1); set }); builder.checksum_paths.insert(checksum2.clone(), { let mut set = BTreeSet::new(); set.insert(path2); set }); let (package_meta, _component_content_map) = builder.create_meta(); assert_eq!(package_meta.map.len(), 1); assert!(package_meta.map.contains_key(&checksum1)); assert_eq!(package_meta.map[&checksum1], pkg_id); assert_eq!(package_meta.set.len(), 1); assert_eq!(package_meta.set.iter().next().unwrap().identifier, pkg_id); } #[test] fn test_mapping_builder_create_component_meta() { let mut builder = MappingBuilder { unpackaged_id: Rc::from("unpackaged"), packagemeta: ObjectMetaSet::new(), componentmeta: ObjectMetaSet::new(), checksum_paths: BTreeMap::new(), path_packages: HashMap::new(), path_components: HashMap::new(), skip: HashSet::new(), component_ids: HashSet::new(), rpmsize: 0, }; // Add a package let pkg_id = Rc::from("test-package"); builder.packagemeta.insert(ObjectSourceMeta { identifier: Rc::clone(&pkg_id), name: Rc::from("Test Package"), srcid: Rc::from("test-package-1.0-1.fc39"), change_time_offset: 100, change_frequency: 50, }); // Add a component let comp_id = Rc::from("test-component"); builder.componentmeta.insert(ObjectSourceMeta { identifier: Rc::clone(&comp_id), name: Rc::from("Test Component"), srcid: Rc::from("component:test-component"), change_time_offset: 200, change_frequency: 100, }); // Add paths and checksums let path1 = Utf8PathBuf::from("/usr/bin/test"); let path2 = Utf8PathBuf::from("/usr/share/component-file"); let checksum1 = "abc123".to_string(); let checksum2 = "def456".to_string(); // Associate path1 with package builder.path_packages.insert(path1.clone(), { let mut set = BTreeSet::new(); set.insert(Rc::clone(&pkg_id)); set }); // Associate path2 with component builder.path_components.insert(path2.clone(), { let mut set = BTreeSet::new(); set.insert(Rc::clone(&comp_id)); set }); // Add checksums builder.checksum_paths.insert(checksum1.clone(), { let mut set = BTreeSet::new(); set.insert(path1); set }); builder.checksum_paths.insert(checksum2.clone(), { let mut set = BTreeSet::new(); set.insert(path2.clone()); set }); let (_package_meta, component_content_map) = builder.create_meta(); // Component content map should contain component with Vec<(checksum, path)> assert_eq!(component_content_map.len(), 1); assert!(component_content_map.contains_key(&comp_id)); let entries = &component_content_map[&comp_id]; assert_eq!(entries.len(), 1); assert_eq!(entries[0], (path2.clone(), checksum2.clone())); } #[test] fn test_mapping_builder_mixed_content() { let mut builder = MappingBuilder { unpackaged_id: Rc::from("unpackaged"), packagemeta: ObjectMetaSet::new(), componentmeta: ObjectMetaSet::new(), checksum_paths: BTreeMap::new(), path_packages: HashMap::new(), path_components: HashMap::new(), skip: HashSet::new(), component_ids: HashSet::new(), rpmsize: 0, }; // Add package and component metadata let pkg_id = Rc::from("test-package"); builder.packagemeta.insert(ObjectSourceMeta { identifier: Rc::clone(&pkg_id), name: Rc::from("Test Package"), srcid: Rc::from("test-package-1.0-1.fc39"), change_time_offset: 100, change_frequency: 50, }); let comp_id = Rc::from("test-component"); builder.componentmeta.insert(ObjectSourceMeta { identifier: Rc::clone(&comp_id), name: Rc::from("Test Component"), srcid: Rc::from("component:test-component"), change_time_offset: 200, change_frequency: 100, }); // Add unpackaged content let unpackaged_path = Utf8PathBuf::from("/usr/share/unpackaged"); let unpackaged_checksum = "unpack123".to_string(); builder.checksum_paths.insert(unpackaged_checksum.clone(), { let mut set = BTreeSet::new(); set.insert(unpackaged_path); set }); let (package_meta, _component_content_map) = builder.create_meta(); // Package meta should contain unpackaged content assert_eq!(package_meta.map.len(), 1); assert!(package_meta.map.contains_key(&unpackaged_checksum)); assert_eq!( package_meta.map[&unpackaged_checksum], builder.unpackaged_id ); } #[test] fn test_multiple_components() { let mut builder = MappingBuilder { unpackaged_id: Rc::from("unpackaged"), packagemeta: ObjectMetaSet::new(), componentmeta: ObjectMetaSet::new(), checksum_paths: BTreeMap::new(), path_packages: HashMap::new(), path_components: HashMap::new(), skip: HashSet::new(), component_ids: HashSet::new(), rpmsize: 0, }; // Add multiple components let comp1_id = Rc::from("component1"); let comp2_id = Rc::from("component2"); builder.componentmeta.insert(ObjectSourceMeta { identifier: Rc::clone(&comp1_id), name: Rc::from("Component 1"), srcid: Rc::from("component:component1"), change_time_offset: u32::MAX, change_frequency: u32::MAX, }); builder.componentmeta.insert(ObjectSourceMeta { identifier: Rc::clone(&comp2_id), name: Rc::from("Component 2"), srcid: Rc::from("component:component2"), change_time_offset: u32::MAX, change_frequency: u32::MAX, }); // Add files for each component let path1 = Utf8PathBuf::from("/usr/share/comp1/file"); let path2 = Utf8PathBuf::from("/usr/share/comp2/file"); let checksum1 = "comp1_123".to_string(); let checksum2 = "comp2_456".to_string(); builder.path_components.insert(path1.clone(), { let mut set = BTreeSet::new(); set.insert(Rc::clone(&comp1_id)); set }); builder.path_components.insert(path2.clone(), { let mut set = BTreeSet::new(); set.insert(Rc::clone(&comp2_id)); set }); builder.checksum_paths.insert(checksum1.clone(), { let mut set = BTreeSet::new(); set.insert(path1.clone()); set }); builder.checksum_paths.insert(checksum2.clone(), { let mut set = BTreeSet::new(); set.insert(path2.clone()); set }); let (_package_meta, component_content_map) = builder.create_meta(); // Should contain both components with content_id -> Vec<(checksum, path)> mapping assert_eq!(component_content_map.len(), 2); assert!(component_content_map.contains_key(&comp1_id)); assert!(component_content_map.contains_key(&comp2_id)); let comp1_entries = &component_content_map[&comp1_id]; assert_eq!(comp1_entries.len(), 1); assert_eq!(comp1_entries[0], (path1.clone(), checksum1.clone())); let comp2_entries = &component_content_map[&comp2_id]; assert_eq!(comp2_entries.len(), 1); assert_eq!(comp2_entries[0], (path2.clone(), checksum2.clone())); } #[test] fn test_get_user_component_xattr() -> Result<(), Box<dyn std::error::Error>> { use std::fs; use std::os::fd::AsRawFd; use tempfile::TempDir; // Create temporary directory for our test repo let temp_dir = TempDir::new()?; let repo_path = temp_dir.path().join("repo"); // Create OSTree repository using Rust API let repo = ostree::Repo::create_at( libc::AT_FDCWD, repo_path.to_str().unwrap(), ostree::RepoMode::Archive, None, gio::Cancellable::NONE, )?; // Create temporary work directory with test file let work_dir = temp_dir.path().join("work"); fs::create_dir_all(&work_dir)?; let test_file_path = work_dir.join("test-file"); fs::write(&test_file_path, "test content")?; // Create a mutable tree let mtree = ostree::MutableTree::new(); // Create commit modifier with xattr callback let modifier = ostree::RepoCommitModifier::new( ostree::RepoCommitModifierFlags::NONE, None, // commit filter ); // Create xattr callback that adds user.component xattr to our test file let xattr_callback = move |_repo: &ostree::Repo, path: &str, _file_info: &gio::FileInfo| -> glib::Variant { if path == "/test-file" { // Create the xattr data using byte arrays that glib can understand // OSTree expects xattrs in format: a(ayay) - array of (name, value) byte array pairs // Create xattr tuples as (name, value) pairs where both are byte arrays // The function expects the name to be "user.component\0" and value to be "test-component" let xattr_data = vec![(&b"user.component\0"[..], &b"test-component"[..])]; // Convert to glib::Variant glib::Variant::from(xattr_data) } else { // Return empty xattr array for other files let empty_xattrs: Vec<(&[u8], &[u8])> = vec![]; glib::Variant::from(empty_xattrs) } }; modifier.set_xattr_callback(xattr_callback); // Write directory to mutable tree using OSTree API let work_dir_fd = cap_std::fs::Dir::open_ambient_dir(&work_dir, cap_std::ambient_authority())?; repo.write_dfd_to_mtree( work_dir_fd.as_raw_fd(), ".", &mtree, Some(&modifier), gio::Cancellable::NONE, )?; // Write mutable tree to get root directory let root = repo.write_mtree(&mtree, gio::Cancellable::NONE)?; // Convert gio::File to ostree::RepoFile for write_commit let root_repo_file = root.downcast::<ostree::RepoFile>().unwrap(); // Create metadata for the commit let metadata = glib::VariantDict::new(None); metadata.insert("test", "xattr-test"); let metadata_variant = metadata.to_variant(); // Write commit using OSTree API let commit_checksum = repo.write_commit( None, // parent Some("Test commit"), // subject Some("Test commit with xattr"), // body Some(&metadata_variant), // metadata &root_repo_file, // root gio::Cancellable::NONE, )?; // Read back the commit to get our RepoFile let (commit_root, _) = repo.read_commit(&commit_checksum, gio::Cancellable::NONE)?; let test_file = commit_root.child("test-file"); let repo_file = test_file.downcast::<ostree::RepoFile>().unwrap(); // Test our function with the file that should have the xattr let result = get_user_component_xattr(&repo_file)?; // Verify the function found the xattr and contains our test component assert!(result.is_some()); let result_str = result.unwrap(); assert!(result_str.contains("test-component")); // Test with a file that should have no component xattr (root directory) let root_repo_file = commit_root.downcast::<ostree::RepoFile>().unwrap(); let no_result = get_user_component_xattr(&root_repo_file)?; assert_eq!(no_result, None); Ok(()) } }