Skip to main content

mitosis/
paths.rs

1use crate::error::{ErrorCode, MitosisError, Result};
2use std::ffi::OsString;
3use std::os::unix::fs::PermissionsExt;
4use std::path::PathBuf;
5
6/// Empty `value` counts as unset; a relative one is made absolute so the daemon never depends on its cwd.
7fn resolve(value: Option<OsString>, home_dir: Option<PathBuf>) -> Result<PathBuf> {
8    match value.filter(|v| !v.is_empty()) {
9        Some(p) => Ok(std::path::absolute(p)?),
10        None => {
11            let home = home_dir.ok_or_else(|| {
12                MitosisError::new(ErrorCode::Internal, "HOME is not set")
13                    .with_hint("set MITOSIS_HOME to choose where mitosis keeps its data")
14            })?;
15            Ok(home.join(DEFAULT_DIR))
16        }
17    }
18}
19
20/// The default home under `$HOME`: Application Support on macOS, the XDG data folder on Linux.
21#[cfg(target_os = "macos")]
22const DEFAULT_DIR: &str = "Library/Application Support/mitosis";
23#[cfg(not(target_os = "macos"))]
24const DEFAULT_DIR: &str = ".local/share/mitosis";
25
26/// Where unit tests make homes: `/tmp` on macOS (its `TMPDIR` is too long for socket paths), `TMPDIR`
27/// on Linux (the test harness points it at a file system that can clone).
28#[cfg(test)]
29pub(crate) fn test_tmp() -> PathBuf {
30    if cfg!(target_os = "macos") {
31        PathBuf::from("/tmp")
32    } else {
33        std::env::temp_dir()
34    }
35}
36
37const MARKER: &str = ".mitosis-home";
38
39/// Names Mitosis itself puts in the home root (Finder's `.DS_Store` is tolerated too).
40fn is_own_root_entry(name: &str) -> bool {
41    matches!(
42        name,
43        MARKER | "templates" | "clones" | "sockets" | "logs" | ".DS_Store"
44    ) || name.starts_with("state.db")
45        || name.starts_with("daemon.")
46        || name.starts_with("config.toml")
47}
48
49#[derive(Clone, Debug)]
50pub struct Home {
51    pub root: PathBuf,
52}
53
54impl Home {
55    /// `$MITOSIS_HOME`, else `~/Library/Application Support/mitosis` (macOS) or `~/.local/share/mitosis` (Linux).
56    pub fn from_env() -> Result<Home> {
57        Ok(Home::at(resolve(
58            std::env::var_os("MITOSIS_HOME"),
59            std::env::var_os("HOME").map(PathBuf::from),
60        )?))
61    }
62
63    pub fn at(root: PathBuf) -> Home {
64        Home { root }
65    }
66
67    pub fn templates_dir(&self) -> PathBuf {
68        self.root.join("templates")
69    }
70
71    pub fn clones_dir(&self) -> PathBuf {
72        self.root.join("clones")
73    }
74
75    /// Postgres builds installed by `mitosis pg install`, one folder per `x.y` version.
76    pub fn pg_dir(&self) -> PathBuf {
77        self.root.join("pg")
78    }
79
80    pub fn sockets_dir(&self) -> PathBuf {
81        self.root.join("sockets")
82    }
83
84    pub fn logs_dir(&self) -> PathBuf {
85        self.root.join("logs")
86    }
87
88    pub fn state_db(&self) -> PathBuf {
89        self.root.join("state.db")
90    }
91
92    pub fn daemon_sock(&self) -> PathBuf {
93        self.root.join("daemon.sock")
94    }
95
96    pub fn daemon_lock(&self) -> PathBuf {
97        self.root.join("daemon.lock")
98    }
99
100    pub fn config_file(&self) -> PathBuf {
101        self.root.join("config.toml")
102    }
103
104    /// Marks a folder as a Mitosis home; gc and chmod only ever touch a marked root.
105    pub fn marker(&self) -> PathBuf {
106        self.root.join(MARKER)
107    }
108
109    /// Creates the layout. An existing root without the marker is adopted only when it is empty or
110    /// holds `state.db` plus nothing but Mitosis's own entries; any other folder is refused, so gc
111    /// never sees it.
112    pub fn ensure_dirs(&self) -> Result<()> {
113        if !self.marker().exists() {
114            std::fs::create_dir_all(&self.root)?;
115            let names: Vec<String> = std::fs::read_dir(&self.root)?
116                .flatten()
117                .map(|e| e.file_name().to_string_lossy().into_owned())
118                .filter(|n| n != ".DS_Store")
119                .collect();
120            // Own-named entries alone (say a stray `clones/`) don't prove the folder is ours; a
121            // hand-written `config.toml` alone (set up before the first run) does not make it foreign.
122            let has_db = names.iter().any(|n| n == "state.db");
123            let only_config = names.iter().all(|n| n == "config.toml");
124            let mut foreign: Vec<String> = if !only_config && !has_db {
125                names
126            } else {
127                names
128                    .into_iter()
129                    .filter(|n| !is_own_root_entry(n))
130                    .collect()
131            };
132            if !foreign.is_empty() {
133                foreign.sort();
134                if foreign.len() > 5 {
135                    foreign.truncate(5);
136                    foreign.push("…".into());
137                }
138                return Err(MitosisError::new(
139                    ErrorCode::ForeignHome,
140                    format!(
141                        "{} already holds files Mitosis did not create ({})",
142                        self.root.display(),
143                        foreign.join(", ")
144                    ),
145                )
146                .with_hint(
147                    "MITOSIS_HOME points at a folder Mitosis did not create; choose an empty or new folder",
148                ));
149            }
150            std::fs::write(self.marker(), "")?;
151        }
152        // Clone sockets under sockets/ use trust auth: keep other local users out.
153        std::fs::set_permissions(&self.root, std::fs::Permissions::from_mode(0o700))?;
154        for d in [
155            self.templates_dir(),
156            self.clones_dir(),
157            self.sockets_dir(),
158            self.logs_dir(),
159        ] {
160            std::fs::create_dir_all(d)?;
161        }
162        Ok(())
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn layout_and_ensure_dirs() {
172        let tmp = tempfile::tempdir().unwrap();
173        let h = Home::at(tmp.path().join("m"));
174        h.ensure_dirs().unwrap();
175        for d in [
176            h.templates_dir(),
177            h.clones_dir(),
178            h.sockets_dir(),
179            h.logs_dir(),
180        ] {
181            assert!(d.is_dir());
182        }
183        assert!(h.marker().is_file());
184        assert_eq!(h.state_db(), h.root.join("state.db"));
185        assert_eq!(h.daemon_sock(), h.root.join("daemon.sock"));
186        assert_eq!(h.daemon_lock(), h.root.join("daemon.lock"));
187    }
188
189    #[test]
190    fn home_root_is_private() {
191        let tmp = tempfile::tempdir().unwrap();
192        let h = Home::at(tmp.path().join("m"));
193        std::fs::create_dir(&h.root).unwrap();
194        std::fs::set_permissions(&h.root, std::fs::Permissions::from_mode(0o755)).unwrap();
195        h.ensure_dirs().unwrap();
196        assert_eq!(
197            std::fs::metadata(&h.root).unwrap().permissions().mode() & 0o777,
198            0o700
199        );
200    }
201
202    #[test]
203    fn foreign_home_is_refused_untouched_then_adopted_once_only_own_entries_remain() {
204        let tmp = tempfile::tempdir().unwrap();
205        let h = Home::at(tmp.path().join("m"));
206        std::fs::create_dir(&h.root).unwrap();
207        std::fs::set_permissions(&h.root, std::fs::Permissions::from_mode(0o755)).unwrap();
208        std::fs::create_dir(h.root.join("clones")).unwrap();
209        std::fs::write(h.root.join("notes.txt"), "mine").unwrap();
210        let e = h.ensure_dirs().unwrap_err();
211        assert_eq!(e.code, ErrorCode::ForeignHome);
212        assert!(e.message.contains("notes.txt"), "{e}");
213        assert!(!h.marker().exists());
214        assert_eq!(
215            std::fs::metadata(&h.root).unwrap().permissions().mode() & 0o777,
216            0o755,
217            "an unmarked root is never chmodded"
218        );
219
220        // Only Mitosis's own entries (a home from before the marker existed): adopted.
221        std::fs::remove_file(h.root.join("notes.txt")).unwrap();
222        std::fs::write(h.root.join("state.db"), "").unwrap();
223        std::fs::write(h.root.join("state.db-wal"), "").unwrap();
224        std::fs::write(h.root.join("daemon.lock"), "").unwrap();
225        h.ensure_dirs().unwrap();
226        assert!(h.marker().is_file());
227
228        // Once marked, other files in the root no longer matter.
229        std::fs::write(h.root.join("notes.txt"), "mine").unwrap();
230        h.ensure_dirs().unwrap();
231    }
232
233    #[test]
234    fn unmarked_root_without_state_db_is_foreign() {
235        let tmp = tempfile::tempdir().unwrap();
236        let h = Home::at(tmp.path().join("m"));
237        std::fs::create_dir(&h.root).unwrap();
238        std::fs::create_dir(h.root.join("clones")).unwrap();
239        let e = h.ensure_dirs().unwrap_err();
240        assert_eq!(e.code, ErrorCode::ForeignHome);
241        assert!(!h.marker().exists());
242    }
243
244    #[test]
245    fn root_with_only_config_toml_is_adopted() {
246        let tmp = tempfile::tempdir().unwrap();
247        let h = Home::at(tmp.path().join("m"));
248        std::fs::create_dir(&h.root).unwrap();
249        std::fs::write(h.config_file(), "max_clones = 4\n").unwrap();
250        h.ensure_dirs().unwrap();
251        assert!(h.marker().is_file());
252        assert_eq!(
253            std::fs::read_to_string(h.config_file()).unwrap(),
254            "max_clones = 4\n"
255        );
256    }
257
258    #[test]
259    fn resolve_empty_is_default_and_relative_is_absolute() {
260        let home = Some(PathBuf::from("/Users/x"));
261        let default = PathBuf::from("/Users/x").join(DEFAULT_DIR);
262        assert_eq!(resolve(Some("".into()), home.clone()).unwrap(), default);
263        assert_eq!(resolve(None, home.clone()).unwrap(), default);
264        assert_eq!(
265            resolve(Some("/abs/m".into()), home.clone()).unwrap(),
266            PathBuf::from("/abs/m")
267        );
268        let rel = resolve(Some("rel/m".into()), home).unwrap();
269        assert!(rel.is_absolute());
270        assert!(rel.ends_with("rel/m"));
271        assert!(resolve(None, None).is_err());
272    }
273}