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#[cfg(not(windows))]
85pub(crate) const HOME_DIR_VAR: &str = "$HOME";
86
87#[cfg(windows)]
91pub(crate) const HOME_DIR_VAR: &str = "%USERPROFILE%";
92
93#[cfg(not(windows))]
96pub(crate) const SHEP_HOME_VAR: &str = "$SHEP_HOME";
97
98#[cfg(windows)]
101pub(crate) const SHEP_HOME_VAR: &str = "%SHEP_HOME%";
102
103const DEFAULT_HOME_DIR: &str = ".shep";
105
106#[must_use]
119pub fn shep_home(env: &dyn Fn(&str) -> Option<String>, home_dir: Option<&Path>) -> Option<PathBuf> {
120 match home_dir {
121 Some(dir) => Some(home_under(env, dir)),
122 None => env("SHEP_HOME").map(PathBuf::from),
123 }
124}
125
126fn home_under(env: &dyn Fn(&str) -> Option<String>, home_dir: &Path) -> PathBuf {
133 env("SHEP_HOME")
134 .map(PathBuf::from)
135 .unwrap_or_else(|| home_dir.join(DEFAULT_HOME_DIR))
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ShepPaths {
147 pub home: PathBuf,
149 pub daemon_config: PathBuf,
151 pub dogs_config: PathBuf,
157 pub snapshot: PathBuf,
159 pub logs: PathBuf,
161 pub pids: PathBuf,
163 pub run: PathBuf,
165 pub socket: PathBuf,
175 pub barks: PathBuf,
177 pub kv: PathBuf,
179 pub overrides: PathBuf,
181 pub secrets: PathBuf,
183 pub secrets_cache: PathBuf,
188}
189
190fn fnv1a64(bytes: &[u8]) -> u64 {
196 bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| {
197 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
198 })
199}
200
201impl ShepPaths {
202 #[must_use]
215 pub fn pipe_name(&self) -> String {
216 const MAX_STEM: usize = 64;
218
219 let home = self.home.to_string_lossy();
220 let sanitized: String = home
221 .chars()
222 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
223 .collect();
224 let trimmed = sanitized.trim_matches('-');
225 let stem = trimmed[..trimmed.len().min(MAX_STEM)].trim_end_matches('-');
227 let digest = fnv1a64(home.as_bytes());
228 format!(r"\\.\pipe\shep-{stem}-{digest:016x}")
229 }
230
231 #[must_use]
251 pub fn resolve(env: &dyn Fn(&str) -> Option<String>, home_dir: &Path) -> Self {
252 let home = home_under(env, home_dir);
253 let run = home.join("run");
254 #[cfg_attr(not(windows), allow(unused_mut))]
257 let mut paths = Self {
258 daemon_config: home.join("shep.toml"),
259 dogs_config: home.join("dogs.toml"),
260 snapshot: home.join("flock.json"),
261 logs: home.join("logs"),
262 pids: home.join("pids"),
263 socket: run.join("shep.sock"),
264 barks: home.join("barks.jsonl"),
265 kv: home.join("kv.json"),
266 overrides: home.join("overrides.json"),
267 secrets: home.join("secrets.json"),
268 secrets_cache: home.join("secrets-cache.json"),
269 run,
270 home,
271 };
272 #[cfg(windows)]
275 {
276 paths.socket = PathBuf::from(paths.pipe_name());
277 }
278 paths
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 #[test]
288 fn shep_home_prefers_the_variable_and_falls_back_to_the_default() {
289 use std::path::{Path, PathBuf};
290
291 let named = |_: &str| Some("/srv/shep".to_string());
292 let unset = |_: &str| None;
293 let ada = Path::new("/home/ada");
294
295 assert_eq!(
296 super::shep_home(&named, Some(ada)),
297 Some(PathBuf::from("/srv/shep")),
298 "the variable wins over the default"
299 );
300 assert_eq!(
301 super::shep_home(&named, None),
302 Some(PathBuf::from("/srv/shep")),
303 "and needs no home directory of its own"
304 );
305 assert_eq!(
306 super::shep_home(&unset, Some(ada)),
307 Some(ada.join(".shep")),
308 "the default hangs off the user's home"
309 );
310 assert_eq!(
311 super::shep_home(&unset, None),
312 None,
313 "with neither, there is nothing to name"
314 );
315 assert_eq!(
320 super::shep_home(&|_| Some(String::new()), Some(ada)),
321 Some(PathBuf::new()),
322 "an empty value is taken at its word rather than read as unset"
323 );
324 assert_eq!(
325 super::ShepPaths::resolve(&named, ada).home,
326 super::shep_home(&named, Some(ada)).expect("a home directory was given"),
327 "and the layout is built from the same answer"
328 );
329 }
330
331 #[cfg(windows)]
334 #[test]
335 fn a_verbatim_prefix_is_stripped() {
336 let rewritten = super::strip_verbatim_prefix(std::path::Path::new(r"\\?\C:\tmp\flock.js"));
337 assert_eq!(
338 rewritten.as_os_str(),
339 std::ffi::OsStr::new(r"C:\tmp\flock.js"),
340 "node reads the leading `\\\\` as a UNC share and lstats `C:`, so \
341 the verbatim prefix must not reach it"
342 );
343
344 let plain = std::path::Path::new(r"C:\tmp\flock.js");
345 assert_eq!(
346 super::strip_verbatim_prefix(plain).as_os_str(),
347 plain.as_os_str(),
348 "a path with no verbatim prefix must pass through untouched"
349 );
350 }
351
352 #[cfg(windows)]
356 #[test]
357 fn a_real_canonicalized_path_comes_back_free_of_the_prefix() {
358 let dir = tempfile::tempdir().expect("temp dir");
359 let file = dir.path().join("dog.exe");
360 std::fs::write(&file, b"not really an exe").expect("write file");
361
362 let canonical = std::fs::canonicalize(&file).expect("canonicalize");
363 let rewritten = super::strip_verbatim_prefix(&canonical);
364 let shown = rewritten.display().to_string();
365
366 assert!(
367 !shown.starts_with(r"\\?\"),
368 "the path an operator will read still carries a verbatim prefix: {shown}"
369 );
370 assert!(
371 std::path::Path::new(&shown).is_file(),
372 "stripping the prefix must not break the path: {shown}"
373 );
374 }
375
376 #[cfg(not(windows))]
377 #[test]
378 fn a_unix_path_passes_through_untouched() {
379 let plain = std::path::Path::new("/tmp/flock.js");
380 assert_eq!(
381 super::strip_verbatim_prefix(plain).as_os_str(),
382 plain.as_os_str(),
383 "the non-Windows arm must be an identity"
384 );
385 }
386
387 use super::*;
388 use std::path::Path;
389
390 fn no_env(_: &str) -> Option<String> {
391 None
392 }
393
394 #[test]
395 fn default_layout_under_home_dir() {
396 let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
397 assert_eq!(p.home, Path::new("/home/ada/.shep"));
398 assert_eq!(p.daemon_config, Path::new("/home/ada/.shep/shep.toml"));
399 assert_eq!(p.dogs_config, Path::new("/home/ada/.shep/dogs.toml"));
400 assert_eq!(p.snapshot, Path::new("/home/ada/.shep/flock.json"));
401 assert_eq!(p.logs, Path::new("/home/ada/.shep/logs"));
402 assert_eq!(p.pids, Path::new("/home/ada/.shep/pids"));
403 assert_eq!(p.run, Path::new("/home/ada/.shep/run"));
404 assert_eq!(p.barks, Path::new("/home/ada/.shep/barks.jsonl"));
405 assert_eq!(p.kv, Path::new("/home/ada/.shep/kv.json"));
406 assert_eq!(p.overrides, Path::new("/home/ada/.shep/overrides.json"));
407 assert_eq!(p.secrets, Path::new("/home/ada/.shep/secrets.json"));
408 assert_eq!(
409 p.secrets_cache,
410 Path::new("/home/ada/.shep/secrets-cache.json")
411 );
412 }
413
414 #[test]
418 fn the_control_address_is_a_socket_file_on_unix_and_a_pipe_name_on_windows() {
419 let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
420 #[cfg(unix)]
421 assert_eq!(p.socket, Path::new("/home/ada/.shep/run/shep.sock"));
422 #[cfg(windows)]
423 assert_eq!(
424 p.socket,
425 Path::new(r"\\.\pipe\shep-home-ada--shep-fd394cfc5c93ad12")
426 );
427 #[cfg(windows)]
428 assert_eq!(
429 p.socket,
430 Path::new(&p.pipe_name()),
431 "the resolved address and `pipe_name` must not drift"
432 );
433 }
434
435 #[test]
436 fn shep_home_env_overrides_root() {
437 let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
438 let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
439 assert_eq!(p.home, Path::new("/srv/shep"));
440 #[cfg(unix)]
441 assert_eq!(p.socket, Path::new("/srv/shep/run/shep.sock"));
442 #[cfg(windows)]
443 assert_eq!(
444 p.socket,
445 Path::new(r"\\.\pipe\shep-srv-shep-23b467803966a71a")
446 );
447 }
448
449 #[test]
450 fn pipe_name_is_per_home_and_sanitized() {
451 let env = |key: &str| (key == "SHEP_HOME").then(|| "/home/ada/.shep".to_string());
455 let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
456 assert_eq!(
457 p.pipe_name(),
458 r"\\.\pipe\shep-home-ada--shep-626b4d544f86fe95"
459 );
460 let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
461 let q = ShepPaths::resolve(&env, Path::new("/home/ada"));
462 assert_eq!(q.pipe_name(), r"\\.\pipe\shep-srv-shep-23b467803966a71a");
463 }
464
465 #[test]
473 fn a_stem_longer_than_the_cap_is_cut_and_retrimmed() {
474 let long = format!("/{}/bbbb", "a".repeat(63));
475 let env = |key: &str| (key == "SHEP_HOME").then(|| long.clone());
476 let name = ShepPaths::resolve(&env, Path::new("/home/ada")).pipe_name();
477
478 let stem = name
479 .strip_prefix(r"\\.\pipe\shep-")
480 .expect("the fixed prefix")
481 .rsplit_once('-')
482 .expect("a digest after the stem")
483 .0;
484 assert_eq!(
485 stem,
486 "a".repeat(63),
487 "cut at the cap, then the dash removed"
488 );
489 assert!(
490 !name.contains("--"),
491 "a dash left by the cut would double against the digest's own: {name}"
492 );
493 }
494
495 #[test]
501 fn two_homes_differing_only_past_the_cap_stay_distinct() {
502 let base = "a".repeat(70);
503 let one = format!("/{base}/one");
504 let two = format!("/{base}/two");
505 let env_one = |key: &str| (key == "SHEP_HOME").then(|| one.clone());
506 let env_two = |key: &str| (key == "SHEP_HOME").then(|| two.clone());
507 let a = ShepPaths::resolve(&env_one, Path::new("/home/ada")).pipe_name();
508 let b = ShepPaths::resolve(&env_two, Path::new("/home/ada")).pipe_name();
509 assert_ne!(a, b, "two homes, two pipes, whatever the cut discarded");
510 }
511
512 #[test]
516 fn two_homes_that_sanitize_alike_get_distinct_pipe_names() {
517 let nested = |key: &str| (key == "SHEP_HOME").then(|| r"C:\a\b".to_string());
518 let dashed = |key: &str| (key == "SHEP_HOME").then(|| r"C:\a-b".to_string());
519 let n = ShepPaths::resolve(&nested, Path::new("/home/ada"));
520 let d = ShepPaths::resolve(&dashed, Path::new("/home/ada"));
521 assert!(
522 n.pipe_name().starts_with(r"\\.\pipe\shep-C--a-b-")
523 && d.pipe_name().starts_with(r"\\.\pipe\shep-C--a-b-"),
524 "the readable stem is what collides, and it stays readable: {} vs {}",
525 n.pipe_name(),
526 d.pipe_name()
527 );
528 assert_ne!(
529 n.pipe_name(),
530 d.pipe_name(),
531 "only the digest keeps two homes that sanitize alike off one pipe"
532 );
533 }
534
535 fn os_env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> + use<> {
537 let owned: Vec<(String, OsString)> = pairs
538 .iter()
539 .map(|(k, v)| ((*k).to_string(), OsString::from(*v)))
540 .collect();
541 move |key: &str| owned.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
542 }
543
544 #[test]
545 fn home_is_read_first_on_every_platform() {
546 assert_eq!(
547 user_home(&os_env(&[
548 ("HOME", "/home/ada"),
549 ("USERPROFILE", r"C:\Users\Ada")
550 ])),
551 Some(PathBuf::from("/home/ada"))
552 );
553 }
554
555 #[test]
556 fn nothing_named_resolves_nothing() {
557 assert_eq!(user_home(&os_env(&[])), None);
558 }
559
560 #[test]
563 fn an_empty_value_counts_as_unset() {
564 assert_eq!(user_home(&os_env(&[("HOME", "")])), None);
565 }
566
567 #[cfg(windows)]
572 #[test]
573 fn windows_falls_back_to_userprofile_then_to_the_homedrive_pair() {
574 assert_eq!(
575 user_home(&os_env(&[("USERPROFILE", r"C:\Users\Ada")])),
576 Some(PathBuf::from(r"C:\Users\Ada"))
577 );
578 assert_eq!(
579 user_home(&os_env(&[("HOMEDRIVE", "C:"), ("HOMEPATH", r"\Users\Ada")])),
580 Some(PathBuf::from(r"C:\Users\Ada")),
581 "the two halves concatenate into one path, they do not nest"
582 );
583 assert_eq!(
584 user_home(&os_env(&[("HOMEDRIVE", "C:")])),
585 None,
586 "half the pair names no directory"
587 );
588 }
589
590 #[cfg(not(windows))]
593 #[test]
594 fn unix_ignores_the_windows_variables() {
595 assert_eq!(
596 user_home(&os_env(&[
597 ("USERPROFILE", r"C:\Users\Ada"),
598 ("HOMEDRIVE", "C:"),
599 ("HOMEPATH", r"\Users\Ada"),
600 ])),
601 None
602 );
603 }
604}