Skip to main content

atuin_common/
utils.rs

1use std::borrow::Cow;
2use std::env;
3use std::path::{Path, PathBuf};
4
5use eyre::{Result, eyre};
6
7use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine};
8use getrandom::getrandom;
9use uuid::Uuid;
10
11/// Generate N random bytes, using a cryptographically secure source
12pub fn crypto_random_bytes<const N: usize>() -> [u8; N] {
13    // rand say they are in principle safe for crypto purposes, but that it is perhaps a better
14    // idea to use getrandom for things such as passwords.
15    let mut ret = [0u8; N];
16
17    getrandom(&mut ret).expect("Failed to generate random bytes!");
18
19    ret
20}
21
22/// Generate N random bytes using a cryptographically secure source, return encoded as a string
23pub fn crypto_random_string<const N: usize>() -> String {
24    let bytes = crypto_random_bytes::<N>();
25
26    // We only use this to create a random string, and won't be reversing it to find the original
27    // data - no padding is OK there. It may be in URLs.
28    BASE64_URL_SAFE_NO_PAD.encode(bytes)
29}
30
31pub fn uuid_v7() -> Uuid {
32    Uuid::now_v7()
33}
34
35pub fn uuid_v4() -> String {
36    Uuid::new_v4().as_simple().to_string()
37}
38
39pub fn has_git_dir(path: &str) -> bool {
40    let mut gitdir = PathBuf::from(path);
41    gitdir.push(".git");
42
43    gitdir.exists()
44}
45
46// in a git worktree, .git is a file containing "gitdir: <path>" pointing
47// to the main repo's .git/worktrees/<name> directory. follow the pointer
48// back to the main repo root so all worktrees share a workspace.
49fn resolve_git_worktree(path: &Path) -> Option<PathBuf> {
50    let git_path = path.join(".git");
51
52    if !git_path.is_file() {
53        return None;
54    }
55
56    let contents = std::fs::read_to_string(&git_path).ok()?;
57    let gitdir_str = contents.strip_prefix("gitdir: ")?.trim();
58
59    let gitdir = PathBuf::from(gitdir_str);
60    let gitdir = if gitdir.is_absolute() {
61        gitdir
62    } else {
63        path.join(gitdir_str)
64    };
65
66    // walk up from e.g. /repo/.git/worktrees/feature to find /repo
67    let mut candidate = gitdir.as_path();
68    while let Some(parent) = candidate.parent() {
69        if parent.join(".git").is_dir() {
70            return Some(parent.to_path_buf());
71        }
72        candidate = parent;
73    }
74
75    None
76}
77
78// detect if any parent dir has a git repo in it
79// I really don't want to bring in libgit for something simple like this
80// If we start to do anything more advanced, then perhaps
81pub fn in_git_repo(path: &str) -> Option<PathBuf> {
82    let mut gitdir = PathBuf::from(path);
83
84    while gitdir.parent().is_some() && !has_git_dir(gitdir.to_str().unwrap()) {
85        gitdir.pop();
86    }
87
88    // No parent? then we hit root, finding no git
89    if gitdir.parent().is_some() {
90        // if .git is a file (worktree), resolve to the main repo root
91        if let Some(main_repo) = resolve_git_worktree(&gitdir) {
92            return Some(main_repo);
93        }
94        return Some(gitdir);
95    }
96
97    None
98}
99
100// TODO: more reliable, more tested
101// I don't want to use ProjectDirs, it puts config in awkward places on
102// mac. Data too. Seems to be more intended for GUI apps.
103
104pub fn home_dir() -> PathBuf {
105    directories::BaseDirs::new()
106        .map(|d| d.home_dir().to_path_buf())
107        .expect("could not determine home directory")
108}
109
110pub fn config_dir() -> PathBuf {
111    let config_dir =
112        std::env::var("XDG_CONFIG_HOME").map_or_else(|_| home_dir().join(".config"), PathBuf::from);
113    config_dir.join("atuin")
114}
115
116pub fn data_dir() -> PathBuf {
117    let data_dir = std::env::var("XDG_DATA_HOME")
118        .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
119
120    data_dir.join("atuin")
121}
122
123pub fn runtime_dir() -> PathBuf {
124    std::env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from)
125}
126
127pub fn logs_dir() -> PathBuf {
128    home_dir().join(".atuin").join("logs")
129}
130
131pub fn dotfiles_cache_dir() -> PathBuf {
132    // In most cases, this will be  ~/.local/share/atuin/dotfiles/cache
133    let data_dir = std::env::var("XDG_DATA_HOME")
134        .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
135
136    data_dir.join("atuin").join("dotfiles").join("cache")
137}
138
139pub fn get_current_dir() -> String {
140    // Prefer PWD environment variable over cwd if available to better support symbolic links
141    match env::var("PWD") {
142        Ok(v) => v,
143        Err(_) => match env::current_dir() {
144            Ok(dir) => dir.display().to_string(),
145            Err(_) => String::from(""),
146        },
147    }
148}
149
150pub fn broken_symlink<P: Into<PathBuf>>(path: P) -> bool {
151    let path = path.into();
152    path.is_symlink() && !path.exists()
153}
154
155/// Extension trait for anything that can behave like a string to make it easy to escape control
156/// characters.
157///
158/// Intended to help prevent control characters being printed and interpreted by the terminal when
159/// printing history as well as to ensure the commands that appear in the interactive search
160/// reflect the actual command run rather than just the printable characters.
161pub trait Escapable: AsRef<str> {
162    fn escape_control(&self) -> Cow<'_, str> {
163        if !self.as_ref().contains(|c: char| c.is_ascii_control()) {
164            self.as_ref().into()
165        } else {
166            let mut remaining = self.as_ref();
167            // Not a perfect way to reserve space but should reduce the allocations
168            let mut buf = String::with_capacity(remaining.len());
169            while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) {
170                // safe to index with `..i`, `i` and `i+1..` as part[i] is a single byte ascii char
171                buf.push_str(&remaining[..i]);
172                buf.push('^');
173                buf.push(match remaining.as_bytes()[i] {
174                    0x7F => '?',
175                    code => char::from_u32(u32::from(code) + 64).unwrap(),
176                });
177                remaining = &remaining[i + 1..];
178            }
179            buf.push_str(remaining);
180            buf.into()
181        }
182    }
183}
184
185pub fn unquote(s: &str) -> Result<String> {
186    if s.chars().count() < 2 {
187        return Err(eyre!("not enough chars"));
188    }
189
190    let quote = s.chars().next().unwrap();
191
192    // not quoted, do nothing
193    if quote != '"' && quote != '\'' && quote != '`' {
194        return Ok(s.to_string());
195    }
196
197    if s.chars().last().unwrap() != quote {
198        return Err(eyre!("unexpected eof, quotes do not match"));
199    }
200
201    // removes quote characters
202    // the sanity checks performed above ensure that the quotes will be ASCII and this will not
203    // panic
204    let s = &s[1..s.len() - 1];
205
206    Ok(s.to_string())
207}
208
209impl<T: AsRef<str>> Escapable for T {}
210
211#[allow(unsafe_code)]
212#[cfg(test)]
213mod tests {
214    use pretty_assertions::assert_ne;
215
216    use super::*;
217
218    use std::collections::HashSet;
219
220    #[cfg(not(windows))]
221    #[test]
222    fn test_dirs() {
223        // these tests need to be run sequentially to prevent race condition
224        test_config_dir_xdg();
225        test_config_dir();
226        test_data_dir_xdg();
227        test_data_dir();
228    }
229
230    #[cfg(not(windows))]
231    fn test_config_dir_xdg() {
232        // TODO: Audit that the environment access only happens in single-threaded code.
233        unsafe { env::remove_var("HOME") };
234        // TODO: Audit that the environment access only happens in single-threaded code.
235        unsafe { env::set_var("XDG_CONFIG_HOME", "/home/user/custom_config") };
236        assert_eq!(
237            config_dir(),
238            PathBuf::from("/home/user/custom_config/atuin")
239        );
240        // TODO: Audit that the environment access only happens in single-threaded code.
241        unsafe { env::remove_var("XDG_CONFIG_HOME") };
242    }
243
244    #[cfg(not(windows))]
245    fn test_config_dir() {
246        // TODO: Audit that the environment access only happens in single-threaded code.
247        unsafe { env::set_var("HOME", "/home/user") };
248        // TODO: Audit that the environment access only happens in single-threaded code.
249        unsafe { env::remove_var("XDG_CONFIG_HOME") };
250
251        assert_eq!(config_dir(), PathBuf::from("/home/user/.config/atuin"));
252
253        // TODO: Audit that the environment access only happens in single-threaded code.
254        unsafe { env::remove_var("HOME") };
255    }
256
257    #[cfg(not(windows))]
258    fn test_data_dir_xdg() {
259        // TODO: Audit that the environment access only happens in single-threaded code.
260        unsafe { env::remove_var("HOME") };
261        // TODO: Audit that the environment access only happens in single-threaded code.
262        unsafe { env::set_var("XDG_DATA_HOME", "/home/user/custom_data") };
263        assert_eq!(data_dir(), PathBuf::from("/home/user/custom_data/atuin"));
264        // TODO: Audit that the environment access only happens in single-threaded code.
265        unsafe { env::remove_var("XDG_DATA_HOME") };
266    }
267
268    #[cfg(not(windows))]
269    fn test_data_dir() {
270        // TODO: Audit that the environment access only happens in single-threaded code.
271        unsafe { env::set_var("HOME", "/home/user") };
272        // TODO: Audit that the environment access only happens in single-threaded code.
273        unsafe { env::remove_var("XDG_DATA_HOME") };
274        assert_eq!(data_dir(), PathBuf::from("/home/user/.local/share/atuin"));
275        // TODO: Audit that the environment access only happens in single-threaded code.
276        unsafe { env::remove_var("HOME") };
277    }
278
279    #[test]
280    fn uuid_is_unique() {
281        let how_many: usize = 1000000;
282
283        // for peace of mind
284        let mut uuids: HashSet<Uuid> = HashSet::with_capacity(how_many);
285
286        // there will be many in the same millisecond
287        for _ in 0..how_many {
288            let uuid = uuid_v7();
289            uuids.insert(uuid);
290        }
291
292        assert_eq!(uuids.len(), how_many);
293    }
294
295    #[test]
296    fn escape_control_characters() {
297        use super::Escapable;
298        // CSI colour sequence
299        assert_eq!("\x1b[31mfoo".escape_control(), "^[[31mfoo");
300
301        // Tabs count as control chars
302        assert_eq!("foo\tbar".escape_control(), "foo^Ibar");
303
304        // space is in control char range but should be excluded
305        assert_eq!("two words".escape_control(), "two words");
306
307        // unicode multi-byte characters
308        let s = "🐢\x1b[32m🦀";
309        assert_eq!(s.escape_control(), s.replace("\x1b", "^["));
310    }
311
312    #[test]
313    fn escape_no_control_characters() {
314        use super::Escapable as _;
315        assert!(matches!(
316            "no control characters".escape_control(),
317            Cow::Borrowed(_)
318        ));
319        assert!(matches!(
320            "with \x1b[31mcontrol\x1b[0m characters".escape_control(),
321            Cow::Owned(_)
322        ));
323    }
324
325    #[cfg(not(windows))]
326    #[test]
327    fn in_git_repo_regular() {
328        // regular git repo should resolve to the directory containing .git
329        let tmp = std::env::temp_dir().join("atuin-test-regular-git");
330        let _ = std::fs::remove_dir_all(&tmp);
331        let subdir = tmp.join("src").join("deep");
332        std::fs::create_dir_all(&subdir).unwrap();
333        std::fs::create_dir_all(tmp.join(".git")).unwrap();
334
335        let result = in_git_repo(subdir.to_str().unwrap());
336        assert_eq!(result, Some(tmp.clone()));
337
338        std::fs::remove_dir_all(&tmp).unwrap();
339    }
340
341    #[cfg(not(windows))]
342    #[test]
343    fn in_git_repo_worktree_resolves_to_main_repo() {
344        // worktree .git is a file pointing back to the main repo —
345        // in_git_repo should follow it so all worktrees share a workspace
346        let tmp = std::env::temp_dir().join("atuin-test-worktree-git");
347        let _ = std::fs::remove_dir_all(&tmp);
348
349        // main repo at tmp/main with a real .git directory
350        let main_repo = tmp.join("main");
351        let worktree_git_dir = main_repo.join(".git").join("worktrees").join("feature");
352        std::fs::create_dir_all(&worktree_git_dir).unwrap();
353
354        // worktree at tmp/worktree with a .git file
355        let worktree = tmp.join("worktree");
356        let worktree_subdir = worktree.join("src");
357        std::fs::create_dir_all(&worktree_subdir).unwrap();
358        std::fs::write(
359            worktree.join(".git"),
360            format!("gitdir: {}", worktree_git_dir.to_str().unwrap()),
361        )
362        .unwrap();
363
364        // should resolve to the main repo root, not the worktree root
365        let result = in_git_repo(worktree_subdir.to_str().unwrap());
366        assert_eq!(result, Some(main_repo.clone()));
367
368        std::fs::remove_dir_all(&tmp).unwrap();
369    }
370
371    #[test]
372    fn dumb_random_test() {
373        // Obviously not a test of randomness, but make sure we haven't made some
374        // catastrophic error
375
376        assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>());
377        assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>());
378        assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>());
379        assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>());
380        assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>());
381        assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>());
382    }
383}