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 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 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}