container-device-interface 1.1.1

CDI (Container Device Interface), is a specification, for container-runtimes, to support third-party devices.
Documentation
use std::{
    collections::HashMap,
    error::Error,
    fmt, fs,
    path::{Path, PathBuf},
};

use lazy_static::lazy_static;
use path_clean::clean;

use crate::{
    cache::{Cache, CdiOption},
    spec::{read_spec, Spec},
    utils::is_cdi_spec,
};

// DEFAULT_STATIC_DIR is the default directory for static CDI Specs.
const DEFAULT_STATIC_DIR: &str = "/etc/cdi";
// DEFAULT_DYNAMIC_DIR is the default directory for generated CDI Specs
const DEFAULT_DYNAMIC_DIR: &str = "/var/run/cdi";

lazy_static! {
    // DEFAULT_SPEC_DIRS is the default Spec directory configuration.
    // While altering this variable changes the package defaults,
    // the preferred way of overriding the default directories is
    // to use a with_spec_dirs options. Otherwise the change is only
    // effective if it takes place before creating the Registry or
    // other Cache instances.
    pub static ref DEFAULT_SPEC_DIRS: &'static [&'static str] = &[
        DEFAULT_STATIC_DIR,
        DEFAULT_DYNAMIC_DIR,
    ];
}

#[derive(Debug)]
pub struct SpecError {
    message: String,
}

impl SpecError {
    pub fn new(info: &str) -> Self {
        Self {
            message: info.to_owned(),
        }
    }
}

impl fmt::Display for SpecError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "spec error message {}", self.message)
    }
}

impl Error for SpecError {}

pub fn convert_errors(
    spec_errors: &HashMap<String, Vec<Box<dyn Error>>>,
) -> HashMap<String, Vec<Box<dyn Error + Send + Sync + 'static>>> {
    spec_errors
        .iter()
        .map(|(key, value)| {
            (
                key.clone(),
                value
                    .iter()
                    .map(|error| {
                        Box::new(SpecError::new(&error.to_string()))
                            as Box<dyn Error + Send + Sync + 'static>
                    })
                    .collect(),
            )
        })
        .collect()
}

/// with_spec_dirs returns an option to override the CDI Spec directories.
pub fn with_spec_dirs(dirs: &[&str]) -> CdiOption {
    let cleaned_dirs: Vec<String> = dirs
        .iter()
        .map(|dir| {
            clean(PathBuf::from(*dir))
                .into_os_string()
                .into_string()
                .unwrap()
        })
        .collect();

    Box::new(move |cache: &mut Cache| {
        cache.spec_dirs.clone_from(&cleaned_dirs);
    })
}

#[allow(dead_code)]
fn traverse_dir<F>(dir_path: &Path, traverse_fn: &mut F) -> Result<(), Box<dyn Error>>
where
    F: FnMut(&Path) -> Result<(), Box<dyn Error>>,
{
    if let Ok(entries) = fs::read_dir(dir_path) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                traverse_dir(&path, traverse_fn)?;
            } else {
                traverse_fn(&path)?;
            }
        }
    }
    Ok(())
}

// scan_spec_dirs scans the given directories looking for CDI Spec files,
// which are all files with a '.json' or '.yaml' suffix. For every Spec
// file discovered, if it's a cdi spec, then loads a Spec from the file
// with the priority (the index of the directory in the slice of directories given),
// then collect the CDI Specs, and any error encountered while loading the Spec return Error.
#[allow(dead_code)]
pub(crate) fn scan_spec_dirs<P: AsRef<Path>>(dirs: &[P]) -> Result<Vec<Spec>, Box<dyn Error>> {
    let mut scaned_specs = Vec::new();
    for (priority, dir) in dirs.iter().enumerate() {
        let dir_path = dir.as_ref();
        if !dir_path.is_dir() {
            continue;
        }

        let mut operation = |path: &Path| -> Result<(), Box<dyn Error>> {
            if !path.is_dir() && is_cdi_spec(path) {
                let spec = match read_spec(&path.to_path_buf(), priority as i32) {
                    Ok(spec) => spec,
                    Err(err) => {
                        return Err(Box::new(SpecError::new(&err.to_string())));
                    }
                };
                scaned_specs.push(spec);
            }
            Ok(())
        };

        if let Err(e) = traverse_dir(dir_path, &mut operation) {
            return Err(Box::new(SpecError::new(&e.to_string())));
        }
    }

    Ok(scaned_specs)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;
    use std::fs;

    const SPEC_YAML: &str = r#"cdiVersion: "0.6.0"
kind: "vendor.com/device"
devices:
  - name: "gpu0"
    containerEdits:
      env:
        - "VENDOR=1"
"#;

    const SPEC_JSON: &str = r#"{
  "cdiVersion": "0.6.0",
  "kind": "vendor2.com/device",
  "devices": [
    { "name": "d0", "containerEdits": { "env": ["OTHER=1"] } }
  ]
}"#;

    #[test]
    fn with_spec_dirs_cleans_paths() {
        let mut cache = Cache::default();
        with_spec_dirs(&["/etc/cdi/../cdi", "/var/run/cdi/"])(&mut cache);
        assert_eq!(cache.spec_dirs, vec!["/etc/cdi", "/var/run/cdi"]);
    }

    #[test]
    fn spec_error_display_and_conversion() {
        let mut errors: HashMap<String, Vec<Box<dyn Error>>> = HashMap::new();
        errors.insert(
            "/etc/cdi/broken.yaml".to_string(),
            vec![Box::new(SpecError::new("bad spec"))],
        );

        let converted = convert_errors(&errors);
        let msgs = &converted["/etc/cdi/broken.yaml"];
        assert_eq!(msgs.len(), 1);
        assert!(msgs[0].to_string().contains("bad spec"));
    }

    #[test]
    fn scan_finds_specs_recursively_with_dir_index_as_priority() {
        let low = tempfile::tempdir().unwrap();
        let high = tempfile::tempdir().unwrap();
        fs::write(low.path().join("vendor.yaml"), SPEC_YAML).unwrap();
        // json spec in a nested subdir: traversal must descend
        let nested = high.path().join("nested");
        fs::create_dir(&nested).unwrap();
        fs::write(nested.join("vendor2.json"), SPEC_JSON).unwrap();
        // non-spec files are ignored
        fs::write(high.path().join("README.txt"), "not a spec").unwrap();

        let specs = scan_spec_dirs(&[low.path(), high.path()]).unwrap();

        assert_eq!(specs.len(), 2);
        let by_vendor: HashMap<_, _> = specs
            .iter()
            .map(|s| (s.get_vendor().to_string(), s.get_priority()))
            .collect();
        assert_eq!(by_vendor["vendor.com"], 0);
        assert_eq!(by_vendor["vendor2.com"], 1);
    }

    #[test]
    fn scan_skips_missing_dirs() {
        let specs = scan_spec_dirs(&["/nonexistent/cdi/dir"]).unwrap();
        assert!(specs.is_empty());
    }

    #[test]
    fn scan_fails_on_invalid_spec() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("broken.yaml"), "cdiVersion: [not a spec").unwrap();
        assert!(scan_spec_dirs(&[dir.path()]).is_err());
    }
}