adnv 1.5.0

CLI that tracks, checks and auto-updates infrastructure dependency versions across Terraform, Helm, Docker, Git and npm
Documentation
use std::collections::HashMap;
use std::path::Path;

use serde::Deserialize;
use wax::walk::{Entry as _, GlobEntry};

use super::Pipe;
use super::{Error, Result};

/// Transform actions pipe input with a Template engine
#[serde_with::serde_as]
#[derive(Debug, Deserialize)]
pub struct Action {
    #[serde(default)]
    engine: Engine,
    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
    glob: Option<String>,
    variables: HashMap<String, String>,
}

impl Action {
    pub async fn execute(&self, pipe: &mut Pipe) -> Result<()> {
        let input = pipe.input().ok_or(Error::NoInputInPipe)?;
        let output = pipe.output_temp()?;

        tracing::info!(
            "Rendering template: {} -> {}",
            input.display(),
            output.display(),
        );

        crate::util::fs::copy_dir(input.clone(), output.clone()).await?;

        let pattern = self.glob.as_deref().unwrap_or("**/*");
        let glob = wax::Glob::new(pattern)?;

        // The glob is the user's, and the default `**/*` matches the root of
        // the tree and every directory in it as well as the files. Rendering a
        // directory is not a thing: `generate_tera` would try to unlink it and
        // report `Operation not permitted` against a path the user never named.
        let walker = glob
            .walk(&input)
            .filter_map(std::result::Result::ok)
            .filter(|entry| entry.file_type().is_file());

        match self.engine {
            Engine::Tera => self.generate_tera(&input, &output, pattern, walker).await,
        }
    }

    async fn generate_tera(
        &self,
        input: &Path,
        output: &Path,
        pattern: &str,
        walker: impl Iterator<Item = GlobEntry>,
    ) -> Result<()> {
        use tera::{Context, Tera};

        let templates_dir = format!("{input}/{pattern}", input = input.display());
        let mut tera = Tera::new();
        tera.load_from_glob(&templates_dir)?;

        let mut context = Context::new();
        for (key, value) in &self.variables {
            let value = shellexpand::env(value)?;
            context.insert(key.clone(), &value);
        }

        for entry in walker {
            // Safe to unwrap here, we're already supposed to be in `input` directory
            let rel_path = entry.path().strip_prefix(input).unwrap();
            let output_path = output.join(rel_path);

            // `File::create` follows a symbolic link at the destination and
            // truncates whatever it points at. `util::fs::copy_dir` refuses
            // links, so `output` holds none — unlink and create exclusively
            // anyway, so that a later change cannot reintroduce a write
            // through a link planted by a remote source.
            if output_path.exists() {
                std::fs::remove_file(&output_path).map_err(|err| Error::CouldNotOpenFile {
                    path: output_path.clone(),
                    err,
                })?;
            }

            let output_file = std::fs::OpenOptions::new()
                .write(true)
                .create_new(true)
                .open(&output_path)
                .map_err(|err| Error::CouldNotOpenFile {
                    path: output_path,
                    err,
                })?;
            tera.render_to(&rel_path.to_string_lossy(), &context, output_file)?;
        }

        Ok(())
    }
}

#[derive(Debug, Default, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Engine {
    #[default]
    #[serde(alias = "jinja2")]
    Tera,
}

#[cfg(test)]
mod tests {
    use super::Action;
    use crate::ops::run::codegen::actions::Pipe;

    fn action(spec: &str) -> Action {
        serde_yaml_ng::from_str(spec).unwrap()
    }

    /// Hand the action a tree the way a previous step would: `source` fetches
    /// into a directory and pipes it on.
    fn pipe_from(input: &std::path::Path) -> Pipe {
        let mut pipe = Pipe::new();
        pipe.output_path(input);

        pipe.pipe()
    }

    #[tokio::test]
    async fn renders_a_tree_into_the_next_step() {
        let input = tempfile::TempDir::with_prefix("adnv-tpl-in").unwrap();
        std::fs::write(input.path().join("values.yaml"), "name: {{ name }}\n").unwrap();

        let mut pipe = pipe_from(input.path());
        action("variables:\n  name: mattermost\n")
            .execute(&mut pipe)
            .await
            .expect("a tree without links renders");

        // The pipe owns the temporary directory it rendered into, so it has to
        // outlive the read: dropping it deletes the tree.
        let piped = pipe.pipe();
        let output = piped.input().unwrap();
        assert_eq!(
            std::fs::read_to_string(output.join("values.yaml")).unwrap(),
            "name: mattermost\n"
        );
    }

    /// `template` runs on whatever the `source` step fetched, so its input is
    /// remote content. A link in it would be read through by `load_from_glob`
    /// and recreated verbatim in the output by `dircpy`, which is how a chart
    /// from a hostile registry would reach outside the generated tree. The
    /// refusal has to come from `copy_dir`, before either of those runs.
    #[tokio::test]
    async fn refuses_a_tree_holding_a_link_before_reading_through_it() {
        let outside = tempfile::TempDir::with_prefix("adnv-tpl-out").unwrap();
        let secret = outside.path().join("secret");
        std::fs::write(&secret, "INTACT").unwrap();

        let input = tempfile::TempDir::with_prefix("adnv-tpl-in").unwrap();
        std::fs::write(input.path().join("values.yaml"), "name: {{ name }}\n").unwrap();
        std::os::unix::fs::symlink(&secret, input.path().join("link")).unwrap();

        let mut pipe = pipe_from(input.path());
        let err = action("variables:\n  name: mattermost\n")
            .execute(&mut pipe)
            .await
            .expect_err("a tree holding a link is refused");

        assert!(
            matches!(err, crate::Error::SymlinkInSource { .. }),
            "{err:?}"
        );
        assert_eq!(
            std::fs::read_to_string(&secret).unwrap(),
            "INTACT",
            "the link was written through"
        );
    }
}