qcp 0.8.3

Secure remote file copy utility which uses the QUIC protocol over UDP
Documentation
//! Include directive logic
// (c) 2024 Ross Younger

use anyhow::{Context, Result};
use glob::{MatchOptions, glob_with};
use std::path::{MAIN_SEPARATOR, PathBuf};
use std::sync::LazyLock;

use crate::os::{AbstractPlatform as _, Platform};

static HOME_PREFIX: LazyLock<String> = LazyLock::new(|| format!("~{MAIN_SEPARATOR}"));

fn expand_home_directory(path: &str) -> Result<PathBuf> {
    Ok(match path {
        // bare "~"
        "~" => homedir::my_home()?
            .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?,
        s if s.starts_with(&*HOME_PREFIX) => {
            // "~/..."
            let Ok(Some(home)) = homedir::my_home() else {
                anyhow::bail!("could not determine home directory")
            };
            home.join(&s[2..])
        }
        s if s.starts_with('~') => {
            // "~someuser/..."
            let mut parts = s[1..].splitn(2, MAIN_SEPARATOR);
            let Some(username) = parts.next() else {
                anyhow::bail!("could not extract username from path")
            };
            let pb = homedir::home(username)?
                .ok_or_else(|| anyhow::anyhow!("could not determine other home directory"))?;
            if let Some(path) = parts.next() {
                pb.join(path)
            } else {
                pb
            }
        }
        // default: no modification
        s => PathBuf::from(s),
    })
}

/// Wildcard matching and ~ expansion for Include directives
pub fn find_include_files(arg: &str, is_user: bool) -> Result<Vec<String>> {
    // 1. Expand ~
    let mut path = if arg.starts_with('~') {
        anyhow::ensure!(
            is_user,
            "include paths may not start with ~ in a system configuration file"
        );
        expand_home_directory(arg).with_context(|| format!("expanding include expression {arg}"))?
    } else {
        PathBuf::from(arg)
    };
    // 2. Expand relative paths to the relevant directory
    if !path.is_absolute() {
        if is_user {
            // Unix: $HOME/.ssh/
            // Windows: %userprofile%/.ssh/
            let Some(mut buf) = dirs::home_dir() else {
                anyhow::bail!("could not determine home directory");
            };
            buf.push(".ssh");
            buf.push(path);
            path = buf;
        } else {
            let Some(mut buf) = Platform::system_ssh_dir_path() else {
                anyhow::bail!("could not determine system ssh config directory");
            };
            buf.push(path);
            path = buf;
        }
    }
    // 3. Apply wildcards
    let mut result = Vec::new();
    let options = MatchOptions {
        case_sensitive: true,
        require_literal_leading_dot: true,
        require_literal_separator: true,
    };
    for entry in (glob_with(path.to_string_lossy().as_ref(), options)?).flatten() {
        if let Some(s) = entry.to_str() {
            result.push(s.into());
        }
    }
    Ok(result)
}

#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod test {
    use super::{expand_home_directory, find_include_files};
    use pretty_assertions::assert_eq;

    // Some tests for this module are in `qcp_unsafe_tests::ssh_includes`.

    // helper macro to make the test cases easier to read and write
    macro_rules! xhd {
        ($s:expr) => {
            *expand_home_directory($s).unwrap().as_os_str()
        };
    }

    #[test]
    fn home_dir() {
        use super::MAIN_SEPARATOR;

        let home_env = homedir::my_home()
            .unwrap()
            .unwrap_or("dummy-test-home".into());
        assert_eq!(xhd!("~"), *home_env);

        let expect = format!("{}{MAIN_SEPARATOR}file", home_env.display());
        let lookup = format!("~{MAIN_SEPARATOR}file");
        assert_eq!(xhd!(&lookup), *expect);
    }

    #[cfg_attr(target_os = "windows", ignore)] // Windows doesn't seem able to do this
    #[test]
    fn home_dir_other_user() {
        // tricky case. a username.
        // This doesn't work on Nix build tests, as they don't set USER.
        let user = std::env::var("USER").expect("this test requires a USER");
        assert!(!user.is_empty());
        let home = dirs::home_dir().expect("this test requires a home directory");
        let path_part = format!("~{user}");
        assert_eq!(xhd!(&path_part), home);

        let s = "any/old~file/";
        assert_eq!(xhd!("any/old~file/"), *s);
    }

    #[test]
    fn include_paths() {
        let _ = find_include_files("~", false).expect_err("~ in system should be disallowed");
        let _ = dirs::home_dir().expect("this test requires a HOME");
        let testpath = format!("~{}zzznonexistent", super::MAIN_SEPARATOR);
        let d = find_include_files(&testpath, true).expect("home directory should have expanded");
        assert!(d.is_empty());
        let _ = find_include_files("~nonexistent-user-xyzy", true)
            .expect_err("non existent user should have bailed");
    }
    #[test]
    fn relative_paths() {
        let d = find_include_files("nonexistent-really----", false).expect("");
        assert_eq!(d, Vec::<String>::new());
        let d = find_include_files("nonexistent-really----", true).expect("");
        assert!(d.is_empty());
    }
}