Skip to main content

gix_config_value/
path.rs

1use std::{borrow::Cow, path::PathBuf};
2
3use bstr::{BStr, BString, ByteSlice};
4use gix_error::{ErrorExt, ExnResult, OptionExt, ResultExt, not_found, validation};
5
6use crate::Path;
7
8/// Types and functions used when expanding Git configuration paths.
9pub mod interpolate {
10    use std::path::PathBuf;
11
12    /// Options for interpolating paths with [`Path::interpolate()`][crate::Path::interpolate()].
13    #[derive(Clone, Copy)]
14    pub struct Context<'a> {
15        /// The location where gitoxide or git is installed. If `None`, `%(prefix)` in paths will cause an error.
16        pub git_install_dir: Option<&'a std::path::Path>,
17        /// The home directory of the current user. If `None`, `~/` in paths will cause an error.
18        pub home_dir: Option<&'a std::path::Path>,
19        /// A function returning the home directory of a given user.
20        /// If `None`, `~name` or `~name/` in paths will cause an error.
21        pub home_for_user: Option<fn(&str) -> Option<PathBuf>>,
22    }
23
24    impl Default for Context<'_> {
25        fn default() -> Self {
26            Context {
27                git_install_dir: None,
28                home_dir: None,
29                home_for_user: Some(home_for_user),
30            }
31        }
32    }
33
34    /// Obtain the home directory for the given user `name` or return `None` if the user wasn't found
35    /// or any other error occurred.
36    /// It can be used as `home_for_user` parameter in [`Path::interpolate()`][crate::Path::interpolate()].
37    /// Returns `None` on Windows, Android, and WebAssembly targets other than Emscripten.
38    #[cfg_attr(windows, allow(unused_variables))]
39    #[cfg_attr(all(target_family = "wasm", not(target_os = "emscripten")), allow(unused_variables))]
40    pub fn home_for_user(name: &str) -> Option<PathBuf> {
41        #[cfg(not(any(
42            target_os = "android",
43            target_os = "windows",
44            all(target_family = "wasm", not(target_os = "emscripten"))
45        )))]
46        {
47            let cname = std::ffi::CString::new(name).ok()?;
48            // SAFETY: calling this in a threaded program that modifies the pw database is not actually safe.
49            //         TODO: use the `*_r` version, but it's much harder to use.
50            #[expect(unsafe_code)]
51            let pwd = unsafe { libc::getpwnam(cname.as_ptr()) };
52            if pwd.is_null() {
53                None
54            } else {
55                use std::os::unix::ffi::OsStrExt;
56                // 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
57                //         from another thread. Otherwise it lives long enough.
58                #[expect(unsafe_code)]
59                let cstr = unsafe { std::ffi::CStr::from_ptr((*pwd).pw_dir) };
60                Some(std::ffi::OsStr::from_bytes(cstr.to_bytes()).into())
61            }
62        }
63        #[cfg(any(
64            target_os = "android",
65            target_os = "windows",
66            all(target_family = "wasm", not(target_os = "emscripten"))
67        ))]
68        {
69            None
70        }
71    }
72}
73
74impl std::ops::Deref for Path {
75    type Target = BStr;
76
77    fn deref(&self) -> &Self::Target {
78        self.value.as_bstr()
79    }
80}
81
82impl AsRef<[u8]> for Path {
83    fn as_ref(&self) -> &[u8] {
84        self.value.as_ref()
85    }
86}
87
88impl AsRef<BStr> for Path {
89    fn as_ref(&self) -> &BStr {
90        self.value.as_bstr()
91    }
92}
93
94impl From<BString> for Path {
95    fn from(mut value: BString) -> Self {
96        /// The prefix used to mark a path as optional in Git configuration files.
97        const OPTIONAL_PREFIX: &[u8] = b":(optional)";
98
99        if value.starts_with(OPTIONAL_PREFIX) {
100            value.drain(..OPTIONAL_PREFIX.len());
101            Path {
102                value,
103                is_optional: true,
104            }
105        } else {
106            Path {
107                value,
108                is_optional: false,
109            }
110        }
111    }
112}
113
114impl From<Cow<'_, BStr>> for Path {
115    fn from(value: Cow<'_, BStr>) -> Self {
116        Path::from(value.into_owned())
117    }
118}
119
120impl From<&BStr> for Path {
121    fn from(value: &BStr) -> Self {
122        Path::from(value.to_owned())
123    }
124}
125
126impl From<&str> for Path {
127    fn from(value: &str) -> Self {
128        Path::from(BString::from(value))
129    }
130}
131
132impl Path {
133    /// Interpolates this path into a path usable on the file system.
134    ///
135    /// If this path starts with `~/` or `~` or `~user` or `%(prefix)/`
136    ///  - `~` or `~/` is expanded to the value of `home_dir`. The caller can use the [dirs](https://crates.io/crates/dirs) crate to obtain it.
137    ///    If it is required but not set, an error is produced.
138    ///  - `~user` or `~user/` to the specified user’s home directory, e.g `~alice` might get expanded to `/home/alice` on linux, but requires
139    ///    the `home_for_user` function to be provided.
140    ///    The default lookup uses `getpwnam` where available.
141    ///  - `%(prefix)/` is expanded to the location where `gitoxide` is installed.
142    ///    This location is not known at compile time and therefore need to be
143    ///    optionally provided by the caller through `git_install_dir`.
144    ///
145    /// Any other, non-empty path value is returned unchanged and error is returned in case of an empty path value or if the required
146    /// input wasn't provided.
147    /// UTF-8 conversion failures include the invalid path or username bytes as `input`
148    /// [metadata](gix_error::Exn::metadata()).
149    pub fn interpolate(
150        self,
151        interpolate::Context {
152            git_install_dir,
153            home_dir,
154            home_for_user,
155        }: interpolate::Context<'_>,
156    ) -> ExnResult<PathBuf> {
157        if self.is_empty() {
158            return Err(not_found("path is missing").raise_erased());
159        }
160
161        const PREFIX: &[u8] = b"%(prefix)/";
162        if self.starts_with(PREFIX) {
163            let git_install_dir = git_install_dir.ok_or_raise_erased(|| not_found("git install dir is missing"))?;
164            let (_prefix, path_without_trailing_slash) = self.split_at(PREFIX.len());
165            let path_without_trailing_slash =
166                gix_path::try_from_bstring(path_without_trailing_slash).or_raise_erased(|| {
167                    validation("Ill-formed UTF-8 in path past %(prefix)").with("input", path_without_trailing_slash)
168                })?;
169            Ok(git_install_dir.join(path_without_trailing_slash))
170        } else if let Some(val) = self.strip_prefix(b"~") {
171            let (username, path) = match val.split_once_str(b"/") {
172                Some((username, path)) => (username, Some(path)),
173                None => (val, None),
174            };
175            let (mut home, what) = if username.is_empty() {
176                (
177                    home_dir
178                        .ok_or_raise_erased(|| not_found("home dir is missing"))?
179                        .to_path_buf(),
180                    "path past ~/",
181                )
182            } else {
183                (
184                    Self::home_for_username(
185                        username,
186                        home_for_user.ok_or_raise_erased(|| not_found("home for user lookup is missing"))?,
187                    )?,
188                    "path past ~user/",
189                )
190            };
191            if let Some(path) = path {
192                home.push(
193                    gix_path::try_from_byte_slice(path)
194                        .or_raise_erased(|| validation(format!("Ill-formed UTF-8 in {what}")).with("input", path))?,
195                );
196            }
197            Ok(home)
198        } else {
199            Ok(gix_path::from_bstr(self.value.as_bstr()).into_owned())
200        }
201    }
202
203    fn home_for_username(username: &[u8], home_for_user: fn(&str) -> Option<PathBuf>) -> ExnResult<PathBuf> {
204        let username = std::str::from_utf8(username)
205            .or_raise_erased(|| validation("Ill-formed UTF-8 in username").with("input", username))?;
206        home_for_user(username).ok_or_raise_erased(|| not_found("pwd user info is missing"))
207    }
208}