Skip to main content

gix_config_value/
path.rs

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