1use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8
9#[cfg(windows)]
20#[must_use]
21pub fn strip_verbatim_prefix(path: &Path) -> std::borrow::Cow<'_, Path> {
22 use std::path::{Component, Prefix};
23
24 let mut components = path.components();
25 let Some(Component::Prefix(prefix)) = components.next() else {
26 return std::borrow::Cow::Borrowed(path);
27 };
28 let Prefix::VerbatimDisk(letter) = prefix.kind() else {
29 return std::borrow::Cow::Borrowed(path);
30 };
31
32 let mut rebuilt = PathBuf::from(format!("{}:\\", char::from(letter)));
33 rebuilt.extend(components.filter(|part| !matches!(part, Component::RootDir)));
34 std::borrow::Cow::Owned(rebuilt)
35}
36
37#[cfg(not(windows))]
41#[must_use]
42pub fn strip_verbatim_prefix(path: &Path) -> std::borrow::Cow<'_, Path> {
43 std::borrow::Cow::Borrowed(path)
44}
45
46#[must_use]
60pub fn user_home(var: &dyn Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
61 let named = |key: &str| var(key).filter(|value| !value.is_empty());
62 #[cfg(not(windows))]
63 {
64 named("HOME").map(PathBuf::from)
65 }
66 #[cfg(windows)]
67 {
68 let split = || {
71 let mut joined = named("HOMEDRIVE")?;
72 joined.push(named("HOMEPATH")?);
73 Some(joined)
74 };
75 named("HOME")
76 .or_else(|| named("USERPROFILE"))
77 .or_else(split)
78 .map(PathBuf::from)
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct ShepPaths {
91 pub home: PathBuf,
93 pub daemon_config: PathBuf,
95 pub dogs_config: PathBuf,
101 pub snapshot: PathBuf,
103 pub logs: PathBuf,
105 pub pids: PathBuf,
107 pub run: PathBuf,
109 pub socket: PathBuf,
119 pub barks: PathBuf,
121 pub kv: PathBuf,
123 pub overrides: PathBuf,
125 pub secrets: PathBuf,
127 pub secrets_cache: PathBuf,
132}
133
134fn fnv1a64(bytes: &[u8]) -> u64 {
140 bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| {
141 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
142 })
143}
144
145impl ShepPaths {
146 #[must_use]
159 pub fn pipe_name(&self) -> String {
160 const MAX_STEM: usize = 64;
162
163 let home = self.home.to_string_lossy();
164 let sanitized: String = home
165 .chars()
166 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
167 .collect();
168 let trimmed = sanitized.trim_matches('-');
169 let stem = trimmed[..trimmed.len().min(MAX_STEM)].trim_end_matches('-');
171 let digest = fnv1a64(home.as_bytes());
172 format!(r"\\.\pipe\shep-{stem}-{digest:016x}")
173 }
174
175 #[must_use]
180 pub fn resolve(env: &dyn Fn(&str) -> Option<String>, home_dir: &Path) -> Self {
181 let home = env("SHEP_HOME")
182 .map(PathBuf::from)
183 .unwrap_or_else(|| home_dir.join(".shep"));
184 let run = home.join("run");
185 #[cfg_attr(not(windows), allow(unused_mut))]
188 let mut paths = Self {
189 daemon_config: home.join("shep.toml"),
190 dogs_config: home.join("dogs.toml"),
191 snapshot: home.join("flock.json"),
192 logs: home.join("logs"),
193 pids: home.join("pids"),
194 socket: run.join("shep.sock"),
195 barks: home.join("barks.jsonl"),
196 kv: home.join("kv.json"),
197 overrides: home.join("overrides.json"),
198 secrets: home.join("secrets.json"),
199 secrets_cache: home.join("secrets-cache.json"),
200 run,
201 home,
202 };
203 #[cfg(windows)]
206 {
207 paths.socket = PathBuf::from(paths.pipe_name());
208 }
209 paths
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 #[cfg(windows)]
218 #[test]
219 fn a_verbatim_prefix_is_stripped() {
220 let rewritten = super::strip_verbatim_prefix(std::path::Path::new(r"\\?\C:\tmp\flock.js"));
221 assert_eq!(
222 rewritten.as_os_str(),
223 std::ffi::OsStr::new(r"C:\tmp\flock.js"),
224 "node reads the leading `\\\\` as a UNC share and lstats `C:`, so \
225 the verbatim prefix must not reach it"
226 );
227
228 let plain = std::path::Path::new(r"C:\tmp\flock.js");
229 assert_eq!(
230 super::strip_verbatim_prefix(plain).as_os_str(),
231 plain.as_os_str(),
232 "a path with no verbatim prefix must pass through untouched"
233 );
234 }
235
236 #[cfg(windows)]
240 #[test]
241 fn a_real_canonicalized_path_comes_back_free_of_the_prefix() {
242 let dir = tempfile::tempdir().expect("temp dir");
243 let file = dir.path().join("dog.exe");
244 std::fs::write(&file, b"not really an exe").expect("write file");
245
246 let canonical = std::fs::canonicalize(&file).expect("canonicalize");
247 let rewritten = super::strip_verbatim_prefix(&canonical);
248 let shown = rewritten.display().to_string();
249
250 assert!(
251 !shown.starts_with(r"\\?\"),
252 "the path an operator will read still carries a verbatim prefix: {shown}"
253 );
254 assert!(
255 std::path::Path::new(&shown).is_file(),
256 "stripping the prefix must not break the path: {shown}"
257 );
258 }
259
260 #[cfg(not(windows))]
261 #[test]
262 fn a_unix_path_passes_through_untouched() {
263 let plain = std::path::Path::new("/tmp/flock.js");
264 assert_eq!(
265 super::strip_verbatim_prefix(plain).as_os_str(),
266 plain.as_os_str(),
267 "the non-Windows arm must be an identity"
268 );
269 }
270
271 use super::*;
272 use std::path::Path;
273
274 fn no_env(_: &str) -> Option<String> {
275 None
276 }
277
278 #[test]
279 fn default_layout_under_home_dir() {
280 let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
281 assert_eq!(p.home, Path::new("/home/ada/.shep"));
282 assert_eq!(p.daemon_config, Path::new("/home/ada/.shep/shep.toml"));
283 assert_eq!(p.dogs_config, Path::new("/home/ada/.shep/dogs.toml"));
284 assert_eq!(p.snapshot, Path::new("/home/ada/.shep/flock.json"));
285 assert_eq!(p.logs, Path::new("/home/ada/.shep/logs"));
286 assert_eq!(p.pids, Path::new("/home/ada/.shep/pids"));
287 assert_eq!(p.run, Path::new("/home/ada/.shep/run"));
288 assert_eq!(p.barks, Path::new("/home/ada/.shep/barks.jsonl"));
289 assert_eq!(p.kv, Path::new("/home/ada/.shep/kv.json"));
290 assert_eq!(p.overrides, Path::new("/home/ada/.shep/overrides.json"));
291 assert_eq!(p.secrets, Path::new("/home/ada/.shep/secrets.json"));
292 assert_eq!(
293 p.secrets_cache,
294 Path::new("/home/ada/.shep/secrets-cache.json")
295 );
296 }
297
298 #[test]
302 fn the_control_address_is_a_socket_file_on_unix_and_a_pipe_name_on_windows() {
303 let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
304 #[cfg(unix)]
305 assert_eq!(p.socket, Path::new("/home/ada/.shep/run/shep.sock"));
306 #[cfg(windows)]
307 assert_eq!(
308 p.socket,
309 Path::new(r"\\.\pipe\shep-home-ada--shep-fd394cfc5c93ad12")
310 );
311 #[cfg(windows)]
312 assert_eq!(
313 p.socket,
314 Path::new(&p.pipe_name()),
315 "the resolved address and `pipe_name` must not drift"
316 );
317 }
318
319 #[test]
320 fn shep_home_env_overrides_root() {
321 let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
322 let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
323 assert_eq!(p.home, Path::new("/srv/shep"));
324 #[cfg(unix)]
325 assert_eq!(p.socket, Path::new("/srv/shep/run/shep.sock"));
326 #[cfg(windows)]
327 assert_eq!(
328 p.socket,
329 Path::new(r"\\.\pipe\shep-srv-shep-23b467803966a71a")
330 );
331 }
332
333 #[test]
334 fn pipe_name_is_per_home_and_sanitized() {
335 let env = |key: &str| (key == "SHEP_HOME").then(|| "/home/ada/.shep".to_string());
339 let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
340 assert_eq!(
341 p.pipe_name(),
342 r"\\.\pipe\shep-home-ada--shep-626b4d544f86fe95"
343 );
344 let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
345 let q = ShepPaths::resolve(&env, Path::new("/home/ada"));
346 assert_eq!(q.pipe_name(), r"\\.\pipe\shep-srv-shep-23b467803966a71a");
347 }
348
349 #[test]
353 fn two_homes_that_sanitize_alike_get_distinct_pipe_names() {
354 let nested = |key: &str| (key == "SHEP_HOME").then(|| r"C:\a\b".to_string());
355 let dashed = |key: &str| (key == "SHEP_HOME").then(|| r"C:\a-b".to_string());
356 let n = ShepPaths::resolve(&nested, Path::new("/home/ada"));
357 let d = ShepPaths::resolve(&dashed, Path::new("/home/ada"));
358 assert!(
359 n.pipe_name().starts_with(r"\\.\pipe\shep-C--a-b-")
360 && d.pipe_name().starts_with(r"\\.\pipe\shep-C--a-b-"),
361 "the readable stem is what collides, and it stays readable: {} vs {}",
362 n.pipe_name(),
363 d.pipe_name()
364 );
365 assert_ne!(
366 n.pipe_name(),
367 d.pipe_name(),
368 "only the digest keeps two homes that sanitize alike off one pipe"
369 );
370 }
371
372 fn os_env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> + use<> {
374 let owned: Vec<(String, OsString)> = pairs
375 .iter()
376 .map(|(k, v)| ((*k).to_string(), OsString::from(*v)))
377 .collect();
378 move |key: &str| owned.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
379 }
380
381 #[test]
382 fn home_is_read_first_on_every_platform() {
383 assert_eq!(
384 user_home(&os_env(&[
385 ("HOME", "/home/ada"),
386 ("USERPROFILE", r"C:\Users\Ada")
387 ])),
388 Some(PathBuf::from("/home/ada"))
389 );
390 }
391
392 #[test]
393 fn nothing_named_resolves_nothing() {
394 assert_eq!(user_home(&os_env(&[])), None);
395 }
396
397 #[test]
400 fn an_empty_value_counts_as_unset() {
401 assert_eq!(user_home(&os_env(&[("HOME", "")])), None);
402 }
403
404 #[cfg(windows)]
409 #[test]
410 fn windows_falls_back_to_userprofile_then_to_the_homedrive_pair() {
411 assert_eq!(
412 user_home(&os_env(&[("USERPROFILE", r"C:\Users\Ada")])),
413 Some(PathBuf::from(r"C:\Users\Ada"))
414 );
415 assert_eq!(
416 user_home(&os_env(&[("HOMEDRIVE", "C:"), ("HOMEPATH", r"\Users\Ada")])),
417 Some(PathBuf::from(r"C:\Users\Ada")),
418 "the two halves concatenate into one path, they do not nest"
419 );
420 assert_eq!(
421 user_home(&os_env(&[("HOMEDRIVE", "C:")])),
422 None,
423 "half the pair names no directory"
424 );
425 }
426
427 #[cfg(not(windows))]
430 #[test]
431 fn unix_ignores_the_windows_variables() {
432 assert_eq!(
433 user_home(&os_env(&[
434 ("USERPROFILE", r"C:\Users\Ada"),
435 ("HOMEDRIVE", "C:"),
436 ("HOMEPATH", r"\Users\Ada"),
437 ])),
438 None
439 );
440 }
441}