adnv 1.5.0

CLI that tracks, checks and auto-updates infrastructure dependency versions across Terraform, Helm, Docker, Git and npm
Documentation
//! Filesystem helpers shared by the codegen actions and sources.

use std::path::{Path, PathBuf};

use crate::{Error, Result};

/// Copies a directory tree, refusing symbolic links anywhere in the source.
///
/// `dircpy` recreates a link verbatim, target included, and never resolves it.
/// A tree fetched from a remote source could therefore plant
/// `config.yaml -> ~/.docker/config.json` into the repository, where a diff
/// shows only a discreet mode change to `120000`. The `template` action makes it
/// worse: it loads the whole tree as Tera templates with a loader that follows
/// links, and writes through `File::create`, which follows them too.
///
/// Refusing is deliberate rather than dereferencing: a source that ships links
/// is doing something the pipeline has no way to interpret safely, and an error
/// says so instead of silently changing what gets copied.
pub async fn copy_dir(source: PathBuf, destination: PathBuf) -> Result<()> {
    tokio::task::spawn_blocking(move || {
        reject_symlinks(&source)?;

        dircpy::CopyBuilder::new(&source, &destination)
            .overwrite(true)
            .run()
            .map_err(|err| Error::copy_directory(err, source, destination))
    })
    .await
    .map_err(|err| Error::io(std::io::Error::other(err), "Copy task panicked"))?
}

fn reject_symlinks(root: &Path) -> Result<()> {
    let mut directories = vec![root.to_path_buf()];

    while let Some(directory) = directories.pop() {
        let entries = std::fs::read_dir(&directory).map_err(|err| {
            Error::io(
                err,
                format!("Could not read directory: {}", directory.display()),
            )
        })?;

        for entry in entries {
            let path = entry
                .map_err(|err| {
                    Error::io(
                        err,
                        format!("Could not read directory: {}", directory.display()),
                    )
                })?
                .path();

            // `symlink_metadata` does not follow the link, unlike `metadata`.
            let file_type = std::fs::symlink_metadata(&path)
                .map_err(|err| Error::io(err, format!("Could not inspect: {}", path.display())))?
                .file_type();

            if file_type.is_symlink() {
                return Err(Error::SymlinkInSource { path });
            }

            if file_type.is_dir() {
                directories.push(path);
            }
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{Error, copy_dir};

    #[tokio::test]
    async fn copies_a_plain_tree() {
        let source = tempfile::TempDir::with_prefix("adnv-src").unwrap();
        let destination = tempfile::TempDir::with_prefix("adnv-dst").unwrap();

        std::fs::create_dir(source.path().join("sub")).unwrap();
        std::fs::write(source.path().join("sub/file.txt"), "content").unwrap();

        copy_dir(
            source.path().to_path_buf(),
            destination.path().to_path_buf(),
        )
        .await
        .expect("a tree without links copies");

        assert_eq!(
            std::fs::read_to_string(destination.path().join("sub/file.txt")).unwrap(),
            "content"
        );
    }

    /// A tree fetched from a remote source must not be able to plant a link
    /// into the user's repository, nor have `template` render through it.
    #[tokio::test]
    async fn refuses_a_symlink_in_the_source() {
        let outside = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(outside.path(), "INTACT").unwrap();

        let source = tempfile::TempDir::with_prefix("adnv-src").unwrap();
        let destination = tempfile::TempDir::with_prefix("adnv-dst").unwrap();

        std::fs::create_dir(source.path().join("nested")).unwrap();
        std::os::unix::fs::symlink(outside.path(), source.path().join("nested/innocent.txt"))
            .unwrap();

        let result = copy_dir(
            source.path().to_path_buf(),
            destination.path().to_path_buf(),
        )
        .await;

        assert!(
            matches!(result, Err(Error::SymlinkInSource { .. })),
            "the symlink was copied: {result:?}"
        );
        assert!(
            !destination.path().join("nested/innocent.txt").exists(),
            "the link reached the destination"
        );
        assert_eq!(
            std::fs::read_to_string(outside.path()).unwrap(),
            "INTACT",
            "the link target was written through"
        );
    }
}