adnv 1.5.0

CLI that tracks, checks and auto-updates infrastructure dependency versions across Terraform, Helm, Docker, Git and npm
Documentation
use std::ops::Deref;
use std::path::{Path, PathBuf};

use serde::Deserialize;
use tempfile::TempDir;

use crate::{Error, Result};

mod copy;
mod document;
mod import;
mod local;
mod output;
mod template;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
    Copy(copy::Action),
    Import(import::Action),
    Local(local::Action),
    Output(output::Action),
    Template(template::Action),
    Document(document::Action),
}

/// Rejects a path that would escape the directory it is resolved against.
///
/// `Path::join` discards its base entirely when handed an absolute path, so an
/// absolute `dest` writes wherever it points instead of inside the pipe — and
/// `document` goes further and calls `remove_dir_all` on its path.
pub(crate) fn validate_contained_path(path: &Path, field: &str) -> Result<()> {
    if path.is_absolute()
        || path
            .components()
            .any(|component| matches!(component, std::path::Component::ParentDir))
    {
        return Err(Error::io(
            std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                format!(
                    "{field} must be relative and cannot contain '..': {}",
                    path.display()
                ),
            ),
            "Invalid path in workflow action",
        ));
    }

    Ok(())
}

impl Action {
    pub async fn execute(&self, pipe: &mut Pipe) -> Result<()> {
        match self {
            Action::Copy(action) => action.execute(pipe).await,
            Action::Import(action) => action.execute(pipe).await,
            Action::Local(action) => action.execute(pipe).await,
            Action::Output(action) => action.execute(pipe).await,
            Action::Template(action) => action.execute(pipe).await,
            Action::Document(action) => action.execute(pipe).await,
        }
    }
}

#[derive(Debug)]
pub struct Pipe(Option<PipePath>, Option<PipePath>);

impl Pipe {
    #[inline]
    pub const fn new() -> Pipe {
        Pipe(None, None)
    }

    #[inline]
    pub fn pipe(self) -> Pipe {
        Pipe(self.1, None)
    }

    #[inline]
    pub fn input(&self) -> Option<PathBuf> {
        self.0.as_deref().map(std::path::Path::to_path_buf)
    }

    #[inline]
    pub fn output(&mut self, path: impl Into<PipePath>) -> PathBuf {
        self.1.insert(path.into()).to_path_buf()
    }

    #[inline]
    pub fn output_path(&mut self, path: &Path) -> PathBuf {
        self.output(path)
    }

    #[inline]
    pub fn output_temp(&mut self) -> Result<PathBuf> {
        Ok(self.output(PipePath::temp()?))
    }

    #[allow(dead_code)]
    #[inline]
    pub fn output_temp_with_prefix(&mut self, prefix: &str) -> Result<PathBuf> {
        Ok(self.output(PipePath::temp_with_prefix(prefix)?))
    }
}

#[derive(Debug)]
pub enum PipePath {
    Path(PathBuf),
    Temp(TempDir),
}

impl PipePath {
    fn path(path: &Path) -> PipePath {
        PipePath::Path(path.into())
    }

    fn temp() -> Result<PipePath> {
        Self::temp_with_prefix("adnv")
    }

    fn temp_with_prefix(prefix: &str) -> Result<PipePath> {
        Ok(PipePath::Temp(
            TempDir::with_prefix(prefix).map_err(Error::TempDirectory)?,
        ))
    }

    pub fn as_path(&self) -> &Path {
        match self {
            PipePath::Path(path) => path,
            PipePath::Temp(dir) => dir.path(),
        }
    }
}

impl<'a> From<&'a Path> for PipePath {
    fn from(path: &'a Path) -> Self {
        Self::path(path)
    }
}

impl From<PathBuf> for PipePath {
    fn from(path: PathBuf) -> Self {
        Self::Path(path)
    }
}

impl From<TempDir> for PipePath {
    fn from(tempdir: TempDir) -> Self {
        Self::Temp(tempdir)
    }
}

impl Deref for PipePath {
    type Target = Path;

    fn deref(&self) -> &Self::Target {
        self.as_path()
    }
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::validate_contained_path;

    #[test]
    fn contained_paths_are_accepted() {
        for path in ["out", "docs/reference", "./docs", "a/b/c.md"] {
            assert!(
                validate_contained_path(Path::new(path), "test").is_ok(),
                "rejected a legitimate path: {path}"
            );
        }
    }

    /// The invariant every action relies on through
    /// `pipe.input().ok_or(Error::NoInputInPipe)?`: a step reads what the
    /// previous one produced, and nothing else.
    #[test]
    fn pipe_carries_the_previous_output_once() {
        let mut pipe = super::Pipe::new();
        assert_eq!(pipe.input(), None, "a fresh pipe has no input");

        pipe.output_path(Path::new("/x"));
        assert_eq!(pipe.input(), None, "an output is not readable as input yet");

        let pipe = pipe.pipe();
        assert_eq!(
            pipe.input(),
            Some(Path::new("/x").to_path_buf()),
            "the output of the previous step became the input"
        );

        // This is the assertion that matters: `pipe()` has to break the chain.
        // If it carried the input over, an action that produces no output would
        // silently see a stale directory instead of failing.
        let pipe = pipe.pipe();
        assert_eq!(
            pipe.input(),
            None,
            "a step that produced nothing must leave no input behind"
        );
    }

    #[test]
    fn escaping_paths_are_rejected() {
        // `/etc` matters most: `document` runs `remove_dir_all` on its path,
        // and `clean` defaults to true.
        for path in [
            "/etc",
            "/home/user/.ssh/authorized_keys",
            "../..",
            "a/../../b",
        ] {
            assert!(
                validate_contained_path(Path::new(path), "test").is_err(),
                "accepted an escaping path: {path}"
            );
        }
    }
}