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}