Skip to main content

shep_core/
paths.rs

1//! On-disk layout of `$SHEP_HOME`
2//!
3//! One resolver, no hidden `std::env` reads: the environment comes in as a
4//! closure so tests and the daemon share one code path.
5
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8
9/// Drops the `\\?\` extended-length prefix Windows' `canonicalize` adds
10///
11/// For paths leaving shep: written to config, shown to an operator, or
12/// handed to another program. Paths compared against each other internally,
13/// such as `serve`'s docroot containment check, must stay canonical on both
14/// sides and must not go through this.
15///
16/// Only unwraps `\\?\C:\`; a verbatim UNC path (`\\?\UNC\server\share`)
17/// passes through unchanged. Not for paths above `MAX_PATH`, where the
18/// prefix is load-bearing rather than decorative.
19#[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/// Passes the path through: only Windows' `canonicalize` prefixes its output
38///
39/// See the Windows sibling for what this exists to undo.
40#[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/// The user's home directory, read through `var` rather than the process
47/// environment
48///
49/// The base [`ShepPaths::resolve`] joins `.shep` onto when nothing names a
50/// `$SHEP_HOME`. A variable set to the empty string counts as unset.
51///
52/// # Platform
53///
54/// Unix reads `HOME` alone. Windows reads `HOME`, then `USERPROFILE`, then
55/// `HOMEDRIVE` and `HOMEPATH` concatenated. Windows sets none of the first:
56/// PowerShell's `$HOME` is a shell variable no child process inherits, and
57/// `cmd.exe` has no such variable at all, so a `HOME`-only lookup resolves
58/// nothing on a stock Windows session.
59#[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        // `HOMEDRIVE` is `C:` and `HOMEPATH` is `\Users\name`: two halves of
69        // one string, not a directory and a child inside it.
70        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/// Resolved filesystem layout for one shep home
83///
84/// All paths are derived from `$SHEP_HOME` (default `<home>/.shep`); nothing
85/// here touches the filesystem. The root itself is created by the CLI's own
86/// `ensure_home`, for the commands that need it before any daemon exists
87/// (`startup` above all), and everything under it by
88/// `shep_daemon::boot::init_dirs` on each boot.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct ShepPaths {
91    /// Root: `$SHEP_HOME`
92    pub home: PathBuf,
93    /// Daemon config: `shep.toml`
94    pub daemon_config: PathBuf,
95    /// A dog's own settings: `dogs.toml`
96    ///
97    /// Separate from [`Self::daemon_config`] rather than a section inside
98    /// it, so lookout can write a dog's config without writing into the
99    /// daemon's own hand-authored file.
100    pub dogs_config: PathBuf,
101    /// Flock snapshot (muster roll): `flock.json`
102    pub snapshot: PathBuf,
103    /// Log directory
104    pub logs: PathBuf,
105    /// Pid-file directory
106    pub pids: PathBuf,
107    /// Runtime dir (sockets; created 0700)
108    pub run: PathBuf,
109    /// The control address the client dials and the daemon answers on.
110    ///
111    /// Unix: a filesystem path, `run/shep.sock`, naming a real AF_UNIX
112    /// socket file. Windows: [`Self::pipe_name`], path-shaped but naming an
113    /// object in the kernel's pipe namespace, not a file on any volume.
114    /// Never derive a directory from this field: `socket.parent()` is
115    /// meaningless on Windows. A pipe has no directory entry to watch, so
116    /// "has the daemon gone" needs a connect attempt there, not
117    /// `Path::exists`.
118    pub socket: PathBuf,
119    /// Bark history ring: `barks.jsonl`
120    pub barks: PathBuf,
121    /// Key/value store: `kv.json`
122    pub kv: PathBuf,
123    /// Operator override store: `overrides.json`
124    pub overrides: PathBuf,
125    /// Secret store: `secrets.json`
126    pub secrets: PathBuf,
127    /// Cached provider values: `secrets-cache.json`
128    ///
129    /// Derived and safe to delete, unlike [`Self::secrets`]: a provider dog
130    /// rewrites it on its next push.
131    pub secrets_cache: PathBuf,
132}
133
134/// FNV-1a, 64-bit, over `bytes`
135///
136/// Hand-rolled rather than reached for from `std`: [`std::hash::DefaultHasher`]
137/// does not promise a stable value across toolchains, and the daemon and a
138/// client built separately have to derive one pipe name and agree on it.
139fn 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    /// Windows named-pipe identity for this home:
147    /// `\\.\pipe\shep-<sanitized>-<digest>`
148    ///
149    /// The sanitized stem is not unique alone: `\`, `:`, `.`, `_` and a
150    /// literal `-` all collapse to `-`, so `C:\a\b` and `C:\a-b` sanitize to
151    /// one string. The digest of the full home path is what keeps two homes
152    /// distinct; without it a collision would not error, it would refuse the
153    /// second daemon as already running and let its CLI drive the first
154    /// home's flock.
155    ///
156    /// Changing this derivation breaks any already-running daemon: it stays
157    /// bound under a name a client built afterward would never dial.
158    #[must_use]
159    pub fn pipe_name(&self) -> String {
160        // Bounds the readable half; a pipe name may be 256 characters.
161        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        // Every character above is ASCII, so this cut cannot split one.
170        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    /// Resolves the layout from an environment lookup and the user's home dir
176    ///
177    /// [`Self::socket`] resolves per-platform: a socket file under `run/` on
178    /// unix, [`Self::pipe_name`] on Windows. Everything else is identical.
179    #[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        // `mut` is read only by the `cfg(windows)` block below; on unix the
186        // value is returned exactly as built.
187        #[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        // Computed here, not inlined above: `pipe_name` reads `self.home`,
204        // and duplicating the sanitizer here would let the two drift.
205        #[cfg(windows)]
206        {
207            paths.socket = PathBuf::from(paths.pipe_name());
208        }
209        paths
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    /// Unit-level because no end-to-end case can pin this reliably: Node's
216    /// handling of a `\\?\` path differs by version.
217    #[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    /// Guards the assumption that `canonicalize` really prefixes the path.
237    /// If a future Windows or std stops adding it, this stays green and the
238    /// strip becomes a no-op rather than a wrong answer.
239    #[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    /// Asserted per-platform rather than skipped on Windows: a silent
299    /// fallback to `run/shep.sock` there would leave a daemon bound to a
300    /// pipe and a client dialing a file that does not exist.
301    #[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        // Both homes come from the env, not the default join: its separator
336        // is host-specific and would give the digest a different value per
337        // platform.
338        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    /// The sanitizer is not injective: `\`, `:` and `-` all become `-`. A
350    /// collision would not error; it would refuse the second daemon as
351    /// already running.
352    #[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    /// A fake environment, so nothing here touches the process's own.
373    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    /// An exported-but-empty `HOME` would otherwise resolve `.shep` relative
398    /// to the working directory, which is a different flock per `cd`.
399    #[test]
400    fn an_empty_value_counts_as_unset() {
401        assert_eq!(user_home(&os_env(&[("HOME", "")])), None);
402    }
403
404    /// The bug this fallback exists for: a stock Windows session exports
405    /// `USERPROFILE` and no `HOME` at all, so `shep` refused every command
406    /// with "none of --home, %SHEP_HOME%, or %USERPROFILE% resolves a root
407    /// directory" until 0.7.1.
408    #[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    /// Unix reads `HOME` alone: a Windows-only fallback firing there would
428    /// resolve a home on a host that deliberately unset one.
429    #[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}