gix-config-value 0.20.0

A crate of the gitoxide project providing git-config value parsing
Documentation
use std::{borrow::Cow, path::PathBuf};

use bstr::{BStr, BString, ByteSlice};
use gix_error::{ErrorExt, ExnResult, OptionExt, ResultExt, not_found, validation};

use crate::Path;

/// Types and functions used when expanding Git configuration paths.
pub mod interpolate {
    use std::path::PathBuf;

    /// Options for interpolating paths with [`Path::interpolate()`][crate::Path::interpolate()].
    #[derive(Clone, Copy)]
    pub struct Context<'a> {
        /// The location where gitoxide or git is installed. If `None`, `%(prefix)` in paths will cause an error.
        pub git_install_dir: Option<&'a std::path::Path>,
        /// The home directory of the current user. If `None`, `~/` in paths will cause an error.
        pub home_dir: Option<&'a std::path::Path>,
        /// A function returning the home directory of a given user.
        /// If `None`, `~name` or `~name/` in paths will cause an error.
        pub home_for_user: Option<fn(&str) -> Option<PathBuf>>,
    }

    impl Default for Context<'_> {
        fn default() -> Self {
            Context {
                git_install_dir: None,
                home_dir: None,
                home_for_user: Some(home_for_user),
            }
        }
    }

    /// Obtain the home directory for the given user `name` or return `None` if the user wasn't found
    /// or any other error occurred.
    /// It can be used as `home_for_user` parameter in [`Path::interpolate()`][crate::Path::interpolate()].
    /// Returns `None` on Windows, Android, and WebAssembly targets other than Emscripten.
    #[cfg_attr(windows, allow(unused_variables))]
    #[cfg_attr(all(target_family = "wasm", not(target_os = "emscripten")), allow(unused_variables))]
    pub fn home_for_user(name: &str) -> Option<PathBuf> {
        #[cfg(not(any(
            target_os = "android",
            target_os = "windows",
            all(target_family = "wasm", not(target_os = "emscripten"))
        )))]
        {
            let cname = std::ffi::CString::new(name).ok()?;
            // SAFETY: calling this in a threaded program that modifies the pw database is not actually safe.
            //         TODO: use the `*_r` version, but it's much harder to use.
            #[expect(unsafe_code)]
            let pwd = unsafe { libc::getpwnam(cname.as_ptr()) };
            if pwd.is_null() {
                None
            } else {
                use std::os::unix::ffi::OsStrExt;
                // SAFETY: pw_dir is a cstr and it lives as long as… well, we hope nobody changes the pw database while we are at it
                //         from another thread. Otherwise it lives long enough.
                #[expect(unsafe_code)]
                let cstr = unsafe { std::ffi::CStr::from_ptr((*pwd).pw_dir) };
                Some(std::ffi::OsStr::from_bytes(cstr.to_bytes()).into())
            }
        }
        #[cfg(any(
            target_os = "android",
            target_os = "windows",
            all(target_family = "wasm", not(target_os = "emscripten"))
        ))]
        {
            None
        }
    }
}

impl std::ops::Deref for Path {
    type Target = BStr;

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

impl AsRef<[u8]> for Path {
    fn as_ref(&self) -> &[u8] {
        self.value.as_ref()
    }
}

impl AsRef<BStr> for Path {
    fn as_ref(&self) -> &BStr {
        self.value.as_bstr()
    }
}

impl From<BString> for Path {
    fn from(mut value: BString) -> Self {
        /// The prefix used to mark a path as optional in Git configuration files.
        const OPTIONAL_PREFIX: &[u8] = b":(optional)";

        if value.starts_with(OPTIONAL_PREFIX) {
            value.drain(..OPTIONAL_PREFIX.len());
            Path {
                value,
                is_optional: true,
            }
        } else {
            Path {
                value,
                is_optional: false,
            }
        }
    }
}

impl From<Cow<'_, BStr>> for Path {
    fn from(value: Cow<'_, BStr>) -> Self {
        Path::from(value.into_owned())
    }
}

impl From<&BStr> for Path {
    fn from(value: &BStr) -> Self {
        Path::from(value.to_owned())
    }
}

impl From<&str> for Path {
    fn from(value: &str) -> Self {
        Path::from(BString::from(value))
    }
}

impl Path {
    /// Interpolates this path into a path usable on the file system.
    ///
    /// If this path starts with `~/` or `~` or `~user` or `%(prefix)/`
    ///  - `~` or `~/` is expanded to the value of `home_dir`. The caller can use the [dirs](https://crates.io/crates/dirs) crate to obtain it.
    ///    If it is required but not set, an error is produced.
    ///  - `~user` or `~user/` to the specified user’s home directory, e.g `~alice` might get expanded to `/home/alice` on linux, but requires
    ///    the `home_for_user` function to be provided.
    ///    The default lookup uses `getpwnam` where available.
    ///  - `%(prefix)/` is expanded to the location where `gitoxide` is installed.
    ///    This location is not known at compile time and therefore need to be
    ///    optionally provided by the caller through `git_install_dir`.
    ///
    /// Any other, non-empty path value is returned unchanged and error is returned in case of an empty path value or if the required
    /// input wasn't provided.
    /// UTF-8 conversion failures include the invalid path or username bytes as `input`
    /// [metadata](gix_error::Exn::metadata()).
    pub fn interpolate(
        self,
        interpolate::Context {
            git_install_dir,
            home_dir,
            home_for_user,
        }: interpolate::Context<'_>,
    ) -> ExnResult<PathBuf> {
        if self.is_empty() {
            return Err(not_found("path is missing").raise_erased());
        }

        const PREFIX: &[u8] = b"%(prefix)/";
        if self.starts_with(PREFIX) {
            let git_install_dir = git_install_dir.ok_or_raise_erased(|| not_found("git install dir is missing"))?;
            let (_prefix, path_without_trailing_slash) = self.split_at(PREFIX.len());
            let path_without_trailing_slash =
                gix_path::try_from_bstring(path_without_trailing_slash).or_raise_erased(|| {
                    validation("Ill-formed UTF-8 in path past %(prefix)").with("input", path_without_trailing_slash)
                })?;
            Ok(git_install_dir.join(path_without_trailing_slash))
        } else if let Some(val) = self.strip_prefix(b"~") {
            let (username, path) = match val.split_once_str(b"/") {
                Some((username, path)) => (username, Some(path)),
                None => (val, None),
            };
            let (mut home, what) = if username.is_empty() {
                (
                    home_dir
                        .ok_or_raise_erased(|| not_found("home dir is missing"))?
                        .to_path_buf(),
                    "path past ~/",
                )
            } else {
                (
                    Self::home_for_username(
                        username,
                        home_for_user.ok_or_raise_erased(|| not_found("home for user lookup is missing"))?,
                    )?,
                    "path past ~user/",
                )
            };
            if let Some(path) = path {
                home.push(
                    gix_path::try_from_byte_slice(path)
                        .or_raise_erased(|| validation(format!("Ill-formed UTF-8 in {what}")).with("input", path))?,
                );
            }
            Ok(home)
        } else {
            Ok(gix_path::from_bstr(self.value.as_bstr()).into_owned())
        }
    }

    fn home_for_username(username: &[u8], home_for_user: fn(&str) -> Option<PathBuf>) -> ExnResult<PathBuf> {
        let username = std::str::from_utf8(username)
            .or_raise_erased(|| validation("Ill-formed UTF-8 in username").with("input", username))?;
        home_for_user(username).ok_or_raise_erased(|| not_found("pwd user info is missing"))
    }
}