Skip to main content

gonzalo_store_fs/
tilde.rs

1//! Leading-`~` expansion for store roots (#211).
2//!
3//! A store root reaches this process **verbatim** whenever no shell is involved
4//! — an MCP client's `env` block, a systemd unit, a container spec, a `.desktop`
5//! entry. `GONZALO_ROOT=~/.gonzalo` then arrives as a literal `~` component and
6//! creates a directory named `~` in whatever the working directory happens to
7//! be, while every query quietly answers from the wrong (empty) store.
8//!
9//! That makes the failure worse than a plain mistake: `~/.gonzalo` is the
10//! natural thing to write, it works when you try it in a shell (the shell
11//! expanded it before `exec`), and it silently does not work in the deployment
12//! that matters.
13
14use std::path::{Path, PathBuf};
15
16/// Expand a leading `~` or `~/…` to the user's home directory.
17///
18/// Only the leading component is expanded. `~user` is left alone — resolving it
19/// needs a passwd lookup, and nobody points a per-user store at another user's
20/// home. Anything else, absolute or relative, is returned unchanged.
21///
22/// With no home directory to expand against, the path is returned unchanged:
23/// a literal `~` is bad, but inventing a different root would be worse.
24pub fn expand_tilde(path: impl AsRef<Path>) -> PathBuf {
25    expand_tilde_with(path, std::env::var_os("HOME").map(PathBuf::from))
26}
27
28/// [`expand_tilde`], against an explicit home directory.
29///
30/// Split out because a test cannot set `HOME`: `std::env::set_var` is `unsafe`
31/// under edition 2024, and this workspace sets `unsafe_code = "forbid"`.
32fn expand_tilde_with(path: impl AsRef<Path>, home: Option<PathBuf>) -> PathBuf {
33    let path = path.as_ref();
34    let Some(home) = home.filter(|h| !h.as_os_str().is_empty()) else {
35        return path.to_path_buf();
36    };
37
38    // Operate on the raw string, not components: `Path` normalizes `~/` to the
39    // component `~`, and rebuilding from components would also silently rewrite
40    // unrelated parts of the path.
41    let Some(raw) = path.to_str() else {
42        return path.to_path_buf(); // non-UTF-8 cannot start with our `~/` form
43    };
44
45    match raw {
46        "~" => home,
47        _ => match raw.strip_prefix("~/") {
48            // `~/.gonzalo` → `$HOME/.gonzalo`. Joining a relative remainder
49            // keeps the separator handling to `Path`.
50            Some(rest) => home.join(rest),
51            // `~user/...`, `./~`, `/tmp/~`, and every ordinary path.
52            None => path.to_path_buf(),
53        },
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    fn home() -> Option<PathBuf> {
62        Some(PathBuf::from("/home/tester"))
63    }
64
65    #[test]
66    fn a_bare_tilde_is_the_home_directory() {
67        assert_eq!(
68            expand_tilde_with("~", home()),
69            PathBuf::from("/home/tester")
70        );
71    }
72
73    #[test]
74    fn a_leading_tilde_slash_expands() {
75        // The case from the ticket: the value an MCP client config carries.
76        assert_eq!(
77            expand_tilde_with("~/.gonzalo", home()),
78            PathBuf::from("/home/tester/.gonzalo")
79        );
80    }
81
82    #[test]
83    fn a_deeper_path_keeps_the_rest_intact() {
84        assert_eq!(
85            expand_tilde_with("~/.gonzalo/graphs/main.db", home()),
86            PathBuf::from("/home/tester/.gonzalo/graphs/main.db")
87        );
88    }
89
90    #[test]
91    fn absolute_and_relative_paths_are_untouched() {
92        for raw in ["/var/lib/gonzalo", "./gonzalo-data", "gonzalo-data", "."] {
93            assert_eq!(expand_tilde_with(raw, home()), PathBuf::from(raw));
94        }
95    }
96
97    #[test]
98    fn a_tilde_that_is_not_leading_is_untouched() {
99        // Expanding these would corrupt a legitimate path — `~` is a perfectly
100        // ordinary directory name once it is not in front.
101        for raw in ["/tmp/~/store", "./~", "store/~/data"] {
102            assert_eq!(expand_tilde_with(raw, home()), PathBuf::from(raw));
103        }
104    }
105
106    #[test]
107    fn a_user_relative_tilde_is_left_alone() {
108        // `~other/store` needs a passwd lookup. Left verbatim rather than
109        // guessed at — and never mistaken for the current user's home.
110        assert_eq!(
111            expand_tilde_with("~other/store", home()),
112            PathBuf::from("~other/store")
113        );
114    }
115
116    #[test]
117    fn without_a_home_the_path_is_returned_unchanged() {
118        // A literal `~` is bad; silently choosing some other root is worse.
119        assert_eq!(
120            expand_tilde_with("~/.gonzalo", None),
121            PathBuf::from("~/.gonzalo")
122        );
123        assert_eq!(
124            expand_tilde_with("~/.gonzalo", Some(PathBuf::new())),
125            PathBuf::from("~/.gonzalo")
126        );
127    }
128}