mod alpm;
mod bigfiles;
mod rpm;
mod xattr;
use std::collections::{BTreeMap, HashMap};
pub const UNCLAIMED_COMPONENT: &str = "chunkah/unclaimed";
use anyhow::{Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use cap_std_ext::cap_std::fs::{Dir, FileType as CapFileType, Metadata, MetadataExt};
use crate::utils;
pub const SECS_PER_DAY: u64 = 60 * 60 * 24;
pub const STABILITY_PERIOD_DAYS: f64 = 7.0;
pub const STABILITY_LOOKBACK_DAYS: u64 = 365;
pub struct ComponentsRepos {
repos: Vec<Box<dyn ComponentsRepo>>,
default_mtime_clamp: u64,
}
#[derive(Debug, Clone)]
pub struct Component {
pub mtime_clamp: u64,
pub stability: f64,
pub files: FileMap,
}
pub type FileMap = BTreeMap<Utf8PathBuf, FileInfo>;
#[derive(Debug, Clone)]
pub struct FileInfo {
pub file_type: FileType,
pub mode: u32,
pub size: u64,
pub uid: u32,
pub gid: u32,
pub mtime: u64,
pub ino: u64,
pub nlink: u64,
pub xattrs: Vec<(String, Vec<u8>)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
Directory,
File,
Symlink,
}
impl FileType {
pub fn from_cap_std(file_type: &CapFileType) -> Option<Self> {
if file_type.is_dir() {
Some(FileType::Directory)
} else if file_type.is_file() {
Some(FileType::File)
} else if file_type.is_symlink() {
Some(FileType::Symlink)
} else {
None
}
}
}
impl FileInfo {
pub fn from_metadata(
metadata: &Metadata,
file_type: FileType,
xattrs: Vec<(String, Vec<u8>)>,
) -> Self {
Self {
file_type,
mode: metadata.mode(),
size: metadata.len(),
uid: metadata.uid(),
gid: metadata.gid(),
mtime: metadata.mtime() as u64,
ino: metadata.ino(),
nlink: metadata.nlink(),
xattrs,
}
}
}
impl ComponentsRepos {
pub fn load(rootfs: &Dir, files: &FileMap, default_mtime_clamp: u64) -> Result<Self> {
let mut repos: Vec<Box<dyn ComponentsRepo>> = Vec::new();
if let Some(repo) =
xattr::XattrRepo::load(files, default_mtime_clamp).context("loading xattrs")?
{
tracing::info!(repo = "xattr", "loaded repo");
repos.push(Box::new(repo));
}
if let Some(repo) =
rpm::RpmRepo::load(rootfs, files, default_mtime_clamp).context("loading rpmdb")?
{
tracing::info!(repo = "rpm", "loaded repo");
repos.push(Box::new(repo));
}
if let Some(repo) = alpm::AlpmComponentsRepo::load(rootfs, files, default_mtime_clamp)
.context("loading alpm database")?
{
tracing::info!(repo = "alpm", "loaded repo");
repos.push(Box::new(repo));
}
if let Some(repo) = bigfiles::BigfilesRepo::load(files, default_mtime_clamp) {
tracing::info!(repo = "bigfiles", "loaded repo");
repos.push(Box::new(repo));
}
Ok(Self {
repos,
default_mtime_clamp,
})
}
pub fn is_empty(&self) -> bool {
self.repos.is_empty()
}
pub fn into_components(
mut self,
rootfs: &Dir,
files: FileMap,
) -> Result<HashMap<String, Component>> {
let mut claims: HashMap<(usize, ComponentId), FileMap> = HashMap::new();
self.repos.sort_by_key(|r| r.default_priority());
for (idx, repo) in self.repos.iter().enumerate() {
tracing::trace!(name = %repo.name(), repo_idx = idx, "repo prioritized");
}
let unclaimed = files;
let unclaimed = claim_pass(
rootfs,
&self.repos,
unclaimed,
&mut claims,
ClaimStrength::Strong,
)
.context("strong claims pass")?;
let unclaimed = claim_pass(
rootfs,
&self.repos,
unclaimed,
&mut claims,
ClaimStrength::Weak,
)
.context("weak claims pass")?;
#[derive(Default)]
struct RepoStats {
components: usize,
total_size: u64,
}
let mut repo_stats: BTreeMap<usize, RepoStats> = BTreeMap::new();
let mut components = HashMap::new();
for ((repo_idx, comp_id), files) in claims {
let repo = &self.repos[repo_idx];
let info = repo.component_info(comp_id);
let full_name = format!("{}/{}", repo.name(), info.name);
let stats = repo_stats.entry(repo_idx).or_default();
stats.components += 1;
stats.total_size += files.values().map(|f| f.size).sum::<u64>();
components.insert(
full_name,
Component {
mtime_clamp: info.mtime_clamp,
stability: info.stability,
files,
},
);
}
for (repo_idx, stats) in &repo_stats {
let repo_name = self.repos[*repo_idx].name();
tracing::info!(repo = repo_name, components = stats.components, size = %utils::format_size(stats.total_size), "repo summary");
}
if !unclaimed.is_empty() {
let size: u64 = unclaimed.values().map(|f| f.size).sum();
tracing::info!(files = unclaimed.len(), size = %utils::format_size(size), "unclaimed files");
components.insert(
UNCLAIMED_COMPONENT.into(),
Component {
mtime_clamp: self.default_mtime_clamp,
stability: 0.0,
files: unclaimed,
},
);
}
Ok(components)
}
}
#[derive(Debug)]
enum ClaimStrength {
Strong,
Weak,
}
fn claim_pass(
rootfs: &Dir,
repos: &[Box<dyn ComponentsRepo>],
files: FileMap,
claims: &mut HashMap<(usize, ComponentId), FileMap>,
strength: ClaimStrength,
) -> Result<FileMap> {
let mut unclaimed = FileMap::new();
for (path, file_info) in files {
let mut claimed = false;
for (repo_idx, repo) in repos.iter().enumerate() {
let ids = match strength {
ClaimStrength::Strong => Ok(repo.strong_claims_for_path(&path, &file_info)),
ClaimStrength::Weak => repo.weak_claims_for_path(rootfs, &path, &file_info),
}
.with_context(|| format!("claiming {path}"))?;
if !ids.is_empty() {
tracing::trace!(path = %path, repo_idx, ids = ?ids, ?strength, "path claimed");
for id in ids {
claims
.entry((repo_idx, id))
.or_default()
.insert(path.clone(), file_info.clone());
}
claimed = true;
break;
}
}
if !claimed {
tracing::trace!(path = %path, ?strength, "path unclaimed after pass");
unclaimed.insert(path, file_info);
}
}
Ok(unclaimed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct ComponentId(usize);
struct ComponentInfo<'a> {
pub name: &'a str,
pub mtime_clamp: u64,
pub stability: f64,
}
trait ComponentsRepo {
fn name(&self) -> &'static str;
fn default_priority(&self) -> usize;
fn strong_claims_for_path(&self, _path: &Utf8Path, _file_info: &FileInfo) -> Vec<ComponentId> {
vec![]
}
fn weak_claims_for_path(
&self,
_rootfs: &Dir,
_path: &Utf8Path,
_file_info: &FileInfo,
) -> Result<Vec<ComponentId>> {
Ok(vec![])
}
fn component_info(&self, id: ComponentId) -> ComponentInfo<'_>;
}
#[cfg(test)]
impl FileInfo {
fn dummy(file_type: FileType) -> Self {
Self {
file_type,
mode: 0,
size: 0,
uid: 0,
gid: 0,
mtime: 0,
ino: 0,
nlink: 1,
xattrs: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use camino::Utf8Path;
use cap_std_ext::cap_std::ambient_authority;
use cap_std_ext::dirext::CapStdExtDirExt;
use super::*;
const RPM_FIXTURE: &str = include_str!("../../tests/fixtures/fedora.qf");
const XATTR_NAME: &str = "user.component";
#[test]
fn test_into_components() {
let tmp = tempfile::tempdir().unwrap();
let rootfs = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
rootfs.create_dir_all("usr/bin").unwrap();
rootfs.create_dir_all("usr/lib64").unwrap();
rootfs.create_dir_all("usr/lib/sysimage/rpm").unwrap();
rootfs.create_dir_all("opt/myapp").unwrap();
rootfs.write("usr/bin/bash", "fake bash").unwrap();
rootfs.write("usr/lib64/libc.so.6", "fake libc").unwrap();
rootfs
.write("usr/lib/sysimage/rpm/rpmdb.sqlite", "fake rpmdb")
.unwrap();
rootfs.write("opt/myapp/config", "config").unwrap();
rootfs.write("opt/myapp/data", "data").unwrap();
rootfs
.setxattr("usr/bin/bash", XATTR_NAME, b"xattr-component")
.unwrap();
rootfs
.setxattr("opt/myapp/data", XATTR_NAME, b"myapp")
.unwrap();
let files = crate::scan::Scanner::new(&rootfs).scan().unwrap();
let xattr_repo = xattr::XattrRepo::load(&files, 0).unwrap().unwrap();
let packages = rpm_qa::load_from_str(RPM_FIXTURE).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let rpm_repo = rpm::RpmRepo::load_from_packages(packages, now).unwrap();
let repos: Vec<Box<dyn ComponentsRepo>> = vec![Box::new(rpm_repo), Box::new(xattr_repo)];
let loaded = ComponentsRepos {
repos,
default_mtime_clamp: 0,
};
let components = loaded.into_components(&rootfs, files).unwrap();
assert!(
components["xattr/xattr-component"]
.files
.contains_key(Utf8Path::new("/usr/bin/bash")),
"/usr/bin/bash should belong to xattr/xattr-component"
);
assert!(
components["rpm/glibc"]
.files
.contains_key(Utf8Path::new("/usr/lib64/libc.so.6")),
"/usr/lib64/libc.so.6 should belong to rpm/glibc"
);
assert!(
components["xattr/myapp"]
.files
.contains_key(Utf8Path::new("/opt/myapp/data")),
"/opt/myapp/data should belong to xattr/myapp"
);
assert!(
components[UNCLAIMED_COMPONENT]
.files
.contains_key(Utf8Path::new("/opt/myapp/config")),
"/opt/myapp/config should be unclaimed"
);
assert!(
components[UNCLAIMED_COMPONENT]
.files
.contains_key(Utf8Path::new("/usr/lib/sysimage/rpm/rpmdb.sqlite")),
"/usr/lib/sysimage/rpm/rpmdb.sqlite should be unclaimed"
);
}
#[test]
fn test_into_components_xattr_only() {
let tmp = tempfile::tempdir().unwrap();
let rootfs = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
rootfs.create_dir_all("opt/myapp").unwrap();
rootfs.write("opt/myapp/config", "config").unwrap();
rootfs.setxattr("opt/myapp", XATTR_NAME, b"myapp").unwrap();
let files = crate::scan::Scanner::new(&rootfs).scan().unwrap();
let xattr_repo = xattr::XattrRepo::load(&files, 0).unwrap().unwrap();
let repos: Vec<Box<dyn ComponentsRepo>> = vec![Box::new(xattr_repo)];
let loaded = ComponentsRepos {
repos,
default_mtime_clamp: 0,
};
let components = loaded.into_components(&rootfs, files).unwrap();
assert!(components.contains_key("xattr/myapp"));
assert!(
components["xattr/myapp"]
.files
.contains_key(Utf8Path::new("/opt/myapp/config"))
);
}
}