use std::collections::HashMap;
use anyhow::{Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use indexmap::IndexMap;
use super::{
ComponentId, ComponentInfo, ComponentsRepo, FileInfo, FileMap, FileType, STABILITY_PERIOD_DAYS,
};
const XATTR_NAME: &str = "user.component";
const UPDATE_INTERVAL_XATTR_NAME: &str = "user.update-interval";
const UPDATE_INTERVAL_DEFAULT: u64 = 7;
const REPO_NAME: &str = "xattr";
pub struct XattrRepo {
components: IndexMap<String, u64>,
path_to_component: HashMap<Utf8PathBuf, ComponentId>,
component_stability: Vec<f64>,
mtime_clamp: u64,
}
impl XattrRepo {
pub fn load(files: &FileMap, mtime_clamp: u64) -> Result<Option<Self>> {
let mut components: IndexMap<String, u64> = IndexMap::new();
let mut path_to_component: HashMap<Utf8PathBuf, ComponentId> = HashMap::new();
let mut update_intervals: Vec<Option<u64>> = Vec::new();
let mut dir_stack: Vec<(&Utf8Path, ComponentId)> = Vec::new();
for (path, file_info) in files {
while let Some((dir_path, _)) = dir_stack.last() {
if path.starts_with(dir_path) && path.as_path() != *dir_path {
break;
}
dir_stack.pop();
}
let own_xattr = get_component_xattr(file_info)
.with_context(|| format!("reading xattr for {}", path))?;
let own_component_id = own_xattr.as_ref().map(|name| {
let idx = components.get_index_of(name).unwrap_or_else(|| {
let idx = components.insert_full(name.clone(), file_info.mtime).0;
update_intervals.push(None);
tracing::trace!(path = %path, name = %name, id = idx, "xattr component created");
idx
});
ComponentId(idx)
});
if file_info.file_type == FileType::Directory
&& let Some(id) = own_component_id
{
dir_stack.push((path.as_path(), id));
}
let effective_id = own_component_id.or_else(|| dir_stack.last().map(|(_, id)| *id));
if let Some(id) = effective_id {
tracing::trace!(path = %path, component_id = id.0, "xattr assignment");
path_to_component.insert(path.clone(), id);
let (_, component_mtime) = components
.get_index_mut(id.0)
.expect("xattr component should have already been created");
*component_mtime = file_info.mtime.max(*component_mtime);
if let Some(interval) = get_update_interval_xattr(file_info)
.with_context(|| format!("reading update interval xattr for {path}"))?
{
let existing = &mut update_intervals[id.0];
match *existing {
Some(prev) if prev != interval => {
let name = &components[id.0];
anyhow::bail!(
"conflicting {UPDATE_INTERVAL_XATTR_NAME} values for component \
{name}: {prev} and {interval} (at {path})"
);
}
Some(_) => {}
None => {
*existing = Some(interval);
tracing::debug!(
path = %path,
component = &components[id.0],
interval_days = interval,
"update-interval xattr set"
);
}
}
}
}
}
if components.is_empty() {
return Ok(None);
}
let mut n_defaulted = 0u32;
let component_stability: Vec<f64> = update_intervals
.iter()
.enumerate()
.map(|(idx, interval)| match interval {
Some(days) => interval_to_stability(*days),
None => {
n_defaulted += 1;
tracing::debug!(
component = &components[idx],
"no {UPDATE_INTERVAL_XATTR_NAME} set, defaulting to {UPDATE_INTERVAL_DEFAULT} days"
);
interval_to_stability(UPDATE_INTERVAL_DEFAULT)
}
})
.collect();
if n_defaulted > 10 {
tracing::warn!(
defaulted = n_defaulted,
"many xattr components without {UPDATE_INTERVAL_XATTR_NAME}, \
consider setting it for better packing"
);
}
tracing::debug!(
components = components.len(),
paths = path_to_component.len(),
"loaded xattr components"
);
Ok(Some(Self {
components,
path_to_component,
component_stability,
mtime_clamp,
}))
}
}
fn get_component_xattr(file_info: &FileInfo) -> Result<Option<String>> {
get_xattr_string(file_info, XATTR_NAME)
}
fn get_update_interval_xattr(file_info: &FileInfo) -> Result<Option<u64>> {
get_xattr_string(file_info, UPDATE_INTERVAL_XATTR_NAME)?
.map(|v| parse_update_interval_xattr(&v))
.transpose()
}
fn get_xattr_string(file_info: &FileInfo, name: &str) -> Result<Option<String>> {
file_info
.xattrs
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| {
String::from_utf8(v.clone())
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in {name} xattr: {e}"))
})
.transpose()
}
fn parse_update_interval_xattr(value: &str) -> Result<u64> {
let interval = match value {
"daily" => 1,
"weekly" => 7,
"biweekly" => 14,
"monthly" => 30,
"quarterly" => 90,
"yearly" => 365,
_ => value
.parse::<u64>()
.with_context(|| format!("invalid {UPDATE_INTERVAL_XATTR_NAME} value: {value:?}"))?,
};
anyhow::ensure!(
interval > 0,
"invalid {UPDATE_INTERVAL_XATTR_NAME} value: must be a positive integer, got {value:?}"
);
Ok(interval)
}
fn interval_to_stability(interval_days: u64) -> f64 {
(-STABILITY_PERIOD_DAYS / interval_days as f64).exp()
}
impl ComponentsRepo for XattrRepo {
fn name(&self) -> &'static str {
REPO_NAME
}
fn default_priority(&self) -> usize {
0
}
fn strong_claims_for_path(
&self,
path: &Utf8Path,
_file_info: &super::FileInfo,
) -> Vec<ComponentId> {
self.path_to_component
.get(path)
.map(|id| vec![*id])
.unwrap_or_default()
}
fn component_info(&self, id: ComponentId) -> ComponentInfo<'_> {
let (name, &mtime) = self
.components
.get_index(id.0)
.expect("invalid ComponentId");
let mtime_clamp = mtime.min(self.mtime_clamp);
ComponentInfo {
name,
mtime_clamp,
stability: self.component_stability[id.0],
}
}
}
#[cfg(test)]
mod tests {
use cap_std_ext::cap_std::ambient_authority;
use cap_std_ext::cap_std::fs::Dir;
use cap_std_ext::dirext::CapStdExtDirExt;
use super::*;
fn setup_rootfs<F>(setup: F) -> (tempfile::TempDir, FileMap)
where
F: FnOnce(&Dir),
{
let tmp = tempfile::tempdir().unwrap();
let rootfs = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
setup(&rootfs);
let files = crate::scan::Scanner::new(&rootfs).scan().unwrap();
(tmp, files)
}
fn set_component(rootfs: &Dir, path: &str, component: &str) {
rootfs
.setxattr(path, XATTR_NAME, component.as_bytes())
.unwrap();
}
fn set_update_interval(rootfs: &Dir, path: &str, value: &str) {
rootfs
.setxattr(path, UPDATE_INTERVAL_XATTR_NAME, value.as_bytes())
.unwrap();
}
fn fi(file_type: FileType) -> super::FileInfo {
super::FileInfo::dummy(file_type)
}
fn assert_component(repo: &XattrRepo, path: &str, file_type: FileType, expected: &str) {
let claims = repo.strong_claims_for_path(Utf8Path::new(path), &fi(file_type));
assert_eq!(claims.len(), 1, "{path} should have exactly one claim");
assert_eq!(
repo.component_info(claims[0]).name,
expected,
"{path} should be claimed by {expected}"
);
}
#[test]
fn test_xattr_file_overrides_directory() {
let (_tmp, files) = setup_rootfs(|rootfs| {
rootfs.create_dir("mydir").unwrap();
set_component(rootfs, "mydir", "dircomponent");
rootfs.write("mydir/special", "content").unwrap();
set_component(rootfs, "mydir/special", "filecomponent");
rootfs.write("mydir/normal", "content").unwrap();
rootfs.write("noattr", "content").unwrap();
});
let repo = XattrRepo::load(&files, 0).unwrap().unwrap();
assert_component(&repo, "/mydir", FileType::Directory, "dircomponent");
assert_component(&repo, "/mydir/normal", FileType::File, "dircomponent");
assert_component(&repo, "/mydir/special", FileType::File, "filecomponent");
let claims = repo.strong_claims_for_path(Utf8Path::new("/noattr"), &fi(FileType::File));
assert!(claims.is_empty());
}
#[test]
fn test_xattr_inheritance() {
let (_tmp, files) = setup_rootfs(|rootfs| {
rootfs.create_dir_all("a/b/c/d").unwrap();
rootfs.write("a/other", "content").unwrap();
rootfs.create_dir("x").unwrap();
rootfs.write("x/file", "content").unwrap();
set_component(rootfs, "a", "compA");
set_component(rootfs, "a/b", "compB");
set_component(rootfs, "a/b/c/d", "compD");
set_component(rootfs, "x", "compX");
});
let repo = XattrRepo::load(&files, 0).unwrap().unwrap();
assert_component(&repo, "/a", FileType::Directory, "compA");
assert_component(&repo, "/a/other", FileType::File, "compA");
assert_component(&repo, "/a/b", FileType::Directory, "compB");
assert_component(&repo, "/a/b/c", FileType::Directory, "compB");
assert_component(&repo, "/a/b/c/d", FileType::Directory, "compD");
assert_component(&repo, "/x", FileType::Directory, "compX");
assert_component(&repo, "/x/file", FileType::File, "compX");
}
#[test]
fn test_xattr_symlink_inherits_from_parent() {
let (_tmp, files) = setup_rootfs(|rootfs| {
rootfs.create_dir("mydir").unwrap();
set_component(rootfs, "mydir", "mycomp");
rootfs.symlink("../somewhere", "mydir/link").unwrap();
});
let repo = XattrRepo::load(&files, 0).unwrap().unwrap();
assert_component(&repo, "/mydir", FileType::Directory, "mycomp");
assert_component(&repo, "/mydir/link", FileType::Symlink, "mycomp");
}
#[test]
fn test_xattr_stability_named_labels() {
assert_eq!(parse_update_interval_xattr("daily").unwrap(), 1);
assert_eq!(parse_update_interval_xattr("weekly").unwrap(), 7);
assert_eq!(parse_update_interval_xattr("biweekly").unwrap(), 14);
assert_eq!(parse_update_interval_xattr("monthly").unwrap(), 30);
assert_eq!(parse_update_interval_xattr("quarterly").unwrap(), 90);
assert_eq!(parse_update_interval_xattr("yearly").unwrap(), 365);
}
#[test]
fn test_xattr_stability_invalid_values() {
assert!(parse_update_interval_xattr("0").is_err());
assert!(parse_update_interval_xattr("-1").is_err());
assert!(parse_update_interval_xattr("abc").is_err());
assert!(parse_update_interval_xattr("3.5").is_err());
assert!(parse_update_interval_xattr("").is_err());
}
#[test]
fn test_xattr_stability_conflicting_values() {
let (_tmp, files) = setup_rootfs(|rootfs| {
rootfs.create_dir("mydir").unwrap();
set_component(rootfs, "mydir", "mycomp");
set_update_interval(rootfs, "mydir", "30");
rootfs.write("mydir/file", "content").unwrap();
set_update_interval(rootfs, "mydir/file", "60");
});
let result = XattrRepo::load(&files, 0);
assert!(result.is_err());
let msg = format!("{:#}", result.err().unwrap());
assert!(msg.contains("conflicting"), "unexpected error: {msg}");
}
#[test]
fn test_xattr_stability_agreeing_values() {
let (_tmp, files) = setup_rootfs(|rootfs| {
rootfs.create_dir("mydir").unwrap();
set_component(rootfs, "mydir", "mycomp");
set_update_interval(rootfs, "mydir", "monthly");
rootfs.write("mydir/file", "content").unwrap();
set_update_interval(rootfs, "mydir/file", "30");
});
let repo = XattrRepo::load(&files, 0).unwrap().unwrap();
let claims = repo.strong_claims_for_path(Utf8Path::new("/mydir"), &fi(FileType::Directory));
let expected = interval_to_stability(30);
assert_eq!(repo.component_info(claims[0]).stability, expected);
}
#[test]
fn test_xattr_stability_not_inherited() {
let (_tmp, files) = setup_rootfs(|rootfs| {
rootfs.create_dir("app1").unwrap();
set_component(rootfs, "app1", "comp1");
set_update_interval(rootfs, "app1", "yearly");
rootfs.write("app1/file", "content").unwrap();
rootfs.create_dir("app2").unwrap();
set_component(rootfs, "app2", "comp2");
rootfs.write("app2/file", "content").unwrap();
});
let repo = XattrRepo::load(&files, 0).unwrap().unwrap();
let claims = repo.strong_claims_for_path(Utf8Path::new("/app1"), &fi(FileType::Directory));
assert_eq!(
repo.component_info(claims[0]).stability,
interval_to_stability(365)
);
let claims = repo.strong_claims_for_path(Utf8Path::new("/app2"), &fi(FileType::Directory));
assert_eq!(
repo.component_info(claims[0]).stability,
interval_to_stability(7)
);
}
}