Skip to main content

sloop/
paths.rs

1use std::env;
2use std::ffi::OsStr;
3use std::fmt;
4use std::os::unix::ffi::OsStrExt;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RepositoryPaths {
9    pub state_dir: PathBuf,
10    pub runtime_dir: PathBuf,
11    pub operator_socket: PathBuf,
12    pub lock_path: PathBuf,
13    pub daemon_log: PathBuf,
14    pub db_path: PathBuf,
15}
16
17pub fn resolve(root: &Path) -> Result<RepositoryPaths, PathError> {
18    let home = env::var_os("HOME").map(PathBuf::from);
19    let xdg_state = absolute_env("XDG_STATE_HOME");
20    let xdg_runtime = absolute_env("XDG_RUNTIME_DIR");
21    let bases = platform_bases(
22        Platform::current(),
23        home.as_deref(),
24        &env::temp_dir(),
25        xdg_state.as_deref(),
26        xdg_runtime.as_deref(),
27    )?;
28    Ok(paths_from_bases(root, &bases))
29}
30
31pub fn repository_key(root: &Path) -> String {
32    let name = root
33        .file_name()
34        .map(safe_name)
35        .filter(|name| !name.is_empty())
36        .unwrap_or_else(|| "repository".into());
37    format!("{name}-{:016x}", path_hash(root))
38}
39
40fn runtime_key(root: &Path) -> String {
41    format!("{:016x}", path_hash(root))
42}
43
44fn path_hash(path: &Path) -> u64 {
45    // FNV-1a is explicit and stable across Rust releases, unlike DefaultHasher.
46    let mut hash = 0xcbf29ce484222325_u64;
47    for byte in path.as_os_str().as_bytes() {
48        hash ^= u64::from(*byte);
49        hash = hash.wrapping_mul(0x100000001b3);
50    }
51    hash
52}
53
54fn safe_name(name: &OsStr) -> String {
55    let mut safe = String::new();
56    let mut last_was_dash = false;
57    for character in name.to_string_lossy().chars().take(32) {
58        let character = if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
59            character
60        } else {
61            '-'
62        };
63        if character == '-' && last_was_dash {
64            continue;
65        }
66        last_was_dash = character == '-';
67        safe.push(character);
68    }
69    safe.trim_matches('-').to_owned()
70}
71
72fn absolute_env(name: &str) -> Option<PathBuf> {
73    env::var_os(name)
74        .map(PathBuf::from)
75        .filter(|path| path.is_absolute())
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79enum Platform {
80    MacOs,
81    OtherUnix,
82}
83
84impl Platform {
85    fn current() -> Self {
86        if cfg!(target_os = "macos") {
87            Self::MacOs
88        } else {
89            Self::OtherUnix
90        }
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95struct BasePaths {
96    state_repositories: PathBuf,
97    log_repositories: Option<PathBuf>,
98    runtime_repositories: PathBuf,
99}
100
101fn platform_bases(
102    platform: Platform,
103    home: Option<&Path>,
104    temp: &Path,
105    xdg_state: Option<&Path>,
106    xdg_runtime: Option<&Path>,
107) -> Result<BasePaths, PathError> {
108    let (state_repositories, log_repositories) = if let Some(state) = xdg_state {
109        (state.join("sloop/repositories"), None)
110    } else {
111        let home = home.ok_or(PathError::MissingHome)?;
112        match platform {
113            Platform::MacOs => (
114                home.join("Library/Application Support/sloop/repositories"),
115                Some(home.join("Library/Logs/sloop")),
116            ),
117            Platform::OtherUnix => (home.join(".local/state/sloop/repositories"), None),
118        }
119    };
120
121    let runtime_repositories = match xdg_runtime {
122        Some(runtime) => runtime.join("sloop"),
123        None if platform == Platform::MacOs => temp.join("sloop"),
124        None => temp.join(format!("sloop-{}", unsafe { libc::geteuid() })),
125    };
126
127    Ok(BasePaths {
128        state_repositories,
129        log_repositories,
130        runtime_repositories,
131    })
132}
133
134fn paths_from_bases(root: &Path, bases: &BasePaths) -> RepositoryPaths {
135    let key = repository_key(root);
136    let state_dir = bases.state_repositories.join(&key);
137    let runtime_dir = bases.runtime_repositories.join(runtime_key(root));
138    let daemon_log = bases.log_repositories.as_ref().map_or_else(
139        || state_dir.join("logs/daemon.ndjson"),
140        |logs| logs.join(&key).join("daemon.ndjson"),
141    );
142    RepositoryPaths {
143        operator_socket: runtime_dir.join("operator.sock"),
144        // The lock protects the state database, so it must follow that
145        // database even when two processes have different runtime roots.
146        lock_path: state_dir.join("daemon.lock"),
147        daemon_log,
148        db_path: state_dir.join("sloop.db"),
149        state_dir,
150        runtime_dir,
151    }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum PathError {
156    MissingHome,
157}
158
159impl fmt::Display for PathError {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        match self {
162            Self::MissingHome => write!(
163                formatter,
164                "HOME is not set and XDG_STATE_HOME does not provide a state directory"
165            ),
166        }
167    }
168}
169
170impl std::error::Error for PathError {}
171
172#[cfg(test)]
173mod tests {
174    use std::path::Path;
175
176    use super::{BasePaths, Platform, paths_from_bases, platform_bases, repository_key};
177
178    #[test]
179    fn repository_keys_are_readable_stable_and_path_specific() {
180        let first = repository_key(Path::new("/work/one/my project"));
181        let second = repository_key(Path::new("/work/two/my project"));
182
183        assert!(first.starts_with("my-project-"), "{first}");
184        assert_eq!(first, repository_key(Path::new("/work/one/my project")));
185        assert_ne!(first, second);
186    }
187
188    #[test]
189    fn xdg_layout_keeps_persistent_and_ephemeral_files_separate() {
190        let bases = platform_bases(
191            Platform::OtherUnix,
192            Some(Path::new("/home/alice")),
193            Path::new("/tmp"),
194            Some(Path::new("/state")),
195            Some(Path::new("/run/user/1000")),
196        )
197        .unwrap();
198        let paths = paths_from_bases(Path::new("/work/repo"), &bases);
199
200        assert!(paths.state_dir.starts_with("/state/sloop/repositories"));
201        assert!(paths.daemon_log.starts_with(&paths.state_dir));
202        assert!(paths.operator_socket.starts_with("/run/user/1000/sloop"));
203        assert!(paths.lock_path.starts_with(&paths.state_dir));
204        assert!(!paths.lock_path.starts_with(&paths.runtime_dir));
205    }
206
207    #[test]
208    fn linux_layout_defaults_to_dot_local_state_and_a_uid_temp_directory() {
209        let bases = platform_bases(
210            Platform::OtherUnix,
211            Some(Path::new("/home/alice")),
212            Path::new("/tmp"),
213            None,
214            None,
215        )
216        .unwrap();
217        let paths = paths_from_bases(Path::new("/work/repo"), &bases);
218
219        assert!(
220            paths
221                .state_dir
222                .starts_with("/home/alice/.local/state/sloop/repositories")
223        );
224        assert!(
225            paths
226                .operator_socket
227                .starts_with(format!("/tmp/sloop-{}", unsafe { libc::geteuid() }))
228        );
229    }
230
231    #[test]
232    fn macos_layout_uses_library_and_the_user_temporary_directory() {
233        let bases = platform_bases(
234            Platform::MacOs,
235            Some(Path::new("/Users/alice")),
236            Path::new("/var/folders/user/T"),
237            None,
238            None,
239        )
240        .unwrap();
241        let paths = paths_from_bases(Path::new("/work/repo"), &bases);
242
243        assert!(
244            paths
245                .state_dir
246                .starts_with("/Users/alice/Library/Application Support/sloop/repositories")
247        );
248        assert!(
249            paths
250                .daemon_log
251                .starts_with("/Users/alice/Library/Logs/sloop")
252        );
253        assert!(
254            paths
255                .operator_socket
256                .starts_with("/var/folders/user/T/sloop")
257        );
258    }
259
260    #[test]
261    fn path_construction_uses_short_runtime_keys() {
262        let bases = BasePaths {
263            state_repositories: "/state".into(),
264            log_repositories: None,
265            runtime_repositories: "/runtime".into(),
266        };
267        let paths = paths_from_bases(Path::new("/a/repository-with-a-very-long-name"), &bases);
268
269        assert_eq!(
270            paths
271                .runtime_dir
272                .file_name()
273                .unwrap()
274                .to_string_lossy()
275                .len(),
276            16
277        );
278    }
279}