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::path::{Path, PathBuf};
7
8/// Drops the `\\?\` extended-length prefix Windows' `canonicalize` adds
9///
10/// For paths leaving shep: written to config, shown to an operator, or
11/// handed to another program. Paths compared against each other internally,
12/// such as `serve`'s docroot containment check, must stay canonical on both
13/// sides and must not go through this.
14///
15/// Only unwraps `\\?\C:\`; a verbatim UNC path (`\\?\UNC\server\share`)
16/// passes through unchanged. Not for paths above `MAX_PATH`, where the
17/// prefix is load-bearing rather than decorative.
18#[cfg(windows)]
19#[must_use]
20pub fn strip_verbatim_prefix(path: &Path) -> std::borrow::Cow<'_, Path> {
21    use std::path::{Component, Prefix};
22
23    let mut components = path.components();
24    let Some(Component::Prefix(prefix)) = components.next() else {
25        return std::borrow::Cow::Borrowed(path);
26    };
27    let Prefix::VerbatimDisk(letter) = prefix.kind() else {
28        return std::borrow::Cow::Borrowed(path);
29    };
30
31    let mut rebuilt = PathBuf::from(format!("{}:\\", char::from(letter)));
32    rebuilt.extend(components.filter(|part| !matches!(part, Component::RootDir)));
33    std::borrow::Cow::Owned(rebuilt)
34}
35
36/// Passes the path through: only Windows' `canonicalize` prefixes its output
37///
38/// See the Windows sibling for what this exists to undo.
39#[cfg(not(windows))]
40#[must_use]
41pub fn strip_verbatim_prefix(path: &Path) -> std::borrow::Cow<'_, Path> {
42    std::borrow::Cow::Borrowed(path)
43}
44
45/// Resolved filesystem layout for one shep home
46///
47/// All paths are derived from `$SHEP_HOME` (default `<home>/.shep`); nothing
48/// here touches the filesystem. The root itself is created by the CLI's own
49/// `ensure_home`, for the commands that need it before any daemon exists
50/// (`startup` above all), and everything under it by
51/// `shep_daemon::boot::init_dirs` on each boot.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ShepPaths {
54    /// Root: `$SHEP_HOME`
55    pub home: PathBuf,
56    /// Daemon config: `shep.toml`
57    pub daemon_config: PathBuf,
58    /// A dog's own settings: `dogs.toml`
59    ///
60    /// Separate from [`Self::daemon_config`] rather than a section inside
61    /// it, so lookout can write a dog's config without writing into the
62    /// daemon's own hand-authored file.
63    pub dogs_config: PathBuf,
64    /// Flock snapshot (muster roll): `flock.json`
65    pub snapshot: PathBuf,
66    /// Log directory
67    pub logs: PathBuf,
68    /// Pid-file directory
69    pub pids: PathBuf,
70    /// Runtime dir (sockets; created 0700)
71    pub run: PathBuf,
72    /// The control address the client dials and the daemon answers on.
73    ///
74    /// Unix: a filesystem path, `run/shep.sock`, naming a real AF_UNIX
75    /// socket file. Windows: [`Self::pipe_name`], path-shaped but naming an
76    /// object in the kernel's pipe namespace, not a file on any volume.
77    /// Never derive a directory from this field: `socket.parent()` is
78    /// meaningless on Windows. A pipe has no directory entry to watch, so
79    /// "has the daemon gone" needs a connect attempt there, not
80    /// `Path::exists`.
81    pub socket: PathBuf,
82    /// Bark history ring: `barks.jsonl`
83    pub barks: PathBuf,
84    /// Key/value store: `kv.json`
85    pub kv: PathBuf,
86    /// Operator override store: `overrides.json`
87    pub overrides: PathBuf,
88    /// Secret store: `secrets.json`
89    pub secrets: PathBuf,
90    /// Cached provider values: `secrets-cache.json`
91    ///
92    /// Derived and safe to delete, unlike [`Self::secrets`]: a provider dog
93    /// rewrites it on its next push.
94    pub secrets_cache: PathBuf,
95}
96
97/// FNV-1a, 64-bit, over `bytes`
98///
99/// Hand-rolled rather than reached for from `std`: [`std::hash::DefaultHasher`]
100/// does not promise a stable value across toolchains, and the daemon and a
101/// client built separately have to derive one pipe name and agree on it.
102fn fnv1a64(bytes: &[u8]) -> u64 {
103    bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| {
104        (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
105    })
106}
107
108impl ShepPaths {
109    /// Windows named-pipe identity for this home:
110    /// `\\.\pipe\shep-<sanitized>-<digest>`
111    ///
112    /// The sanitized stem is not unique alone: `\`, `:`, `.`, `_` and a
113    /// literal `-` all collapse to `-`, so `C:\a\b` and `C:\a-b` sanitize to
114    /// one string. The digest of the full home path is what keeps two homes
115    /// distinct; without it a collision would not error, it would refuse the
116    /// second daemon as already running and let its CLI drive the first
117    /// home's flock.
118    ///
119    /// Changing this derivation breaks any already-running daemon: it stays
120    /// bound under a name a client built afterward would never dial.
121    #[must_use]
122    pub fn pipe_name(&self) -> String {
123        // Bounds the readable half; a pipe name may be 256 characters.
124        const MAX_STEM: usize = 64;
125
126        let home = self.home.to_string_lossy();
127        let sanitized: String = home
128            .chars()
129            .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
130            .collect();
131        let trimmed = sanitized.trim_matches('-');
132        // Every character above is ASCII, so this cut cannot split one.
133        let stem = trimmed[..trimmed.len().min(MAX_STEM)].trim_end_matches('-');
134        let digest = fnv1a64(home.as_bytes());
135        format!(r"\\.\pipe\shep-{stem}-{digest:016x}")
136    }
137
138    /// Resolves the layout from an environment lookup and the user's home dir
139    ///
140    /// [`Self::socket`] resolves per-platform: a socket file under `run/` on
141    /// unix, [`Self::pipe_name`] on Windows. Everything else is identical.
142    #[must_use]
143    pub fn resolve(env: &dyn Fn(&str) -> Option<String>, home_dir: &Path) -> Self {
144        let home = env("SHEP_HOME")
145            .map(PathBuf::from)
146            .unwrap_or_else(|| home_dir.join(".shep"));
147        let run = home.join("run");
148        // `mut` is read only by the `cfg(windows)` block below; on unix the
149        // value is returned exactly as built.
150        #[cfg_attr(not(windows), allow(unused_mut))]
151        let mut paths = Self {
152            daemon_config: home.join("shep.toml"),
153            dogs_config: home.join("dogs.toml"),
154            snapshot: home.join("flock.json"),
155            logs: home.join("logs"),
156            pids: home.join("pids"),
157            socket: run.join("shep.sock"),
158            barks: home.join("barks.jsonl"),
159            kv: home.join("kv.json"),
160            overrides: home.join("overrides.json"),
161            secrets: home.join("secrets.json"),
162            secrets_cache: home.join("secrets-cache.json"),
163            run,
164            home,
165        };
166        // Computed here, not inlined above: `pipe_name` reads `self.home`,
167        // and duplicating the sanitizer here would let the two drift.
168        #[cfg(windows)]
169        {
170            paths.socket = PathBuf::from(paths.pipe_name());
171        }
172        paths
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    /// Unit-level because no end-to-end case can pin this reliably: Node's
179    /// handling of a `\\?\` path differs by version.
180    #[cfg(windows)]
181    #[test]
182    fn a_verbatim_prefix_is_stripped() {
183        let rewritten = super::strip_verbatim_prefix(std::path::Path::new(r"\\?\C:\tmp\flock.js"));
184        assert_eq!(
185            rewritten.as_os_str(),
186            std::ffi::OsStr::new(r"C:\tmp\flock.js"),
187            "node reads the leading `\\\\` as a UNC share and lstats `C:`, so \
188             the verbatim prefix must not reach it"
189        );
190
191        let plain = std::path::Path::new(r"C:\tmp\flock.js");
192        assert_eq!(
193            super::strip_verbatim_prefix(plain).as_os_str(),
194            plain.as_os_str(),
195            "a path with no verbatim prefix must pass through untouched"
196        );
197    }
198
199    /// Guards the assumption that `canonicalize` really prefixes the path.
200    /// If a future Windows or std stops adding it, this stays green and the
201    /// strip becomes a no-op rather than a wrong answer.
202    #[cfg(windows)]
203    #[test]
204    fn a_real_canonicalized_path_comes_back_free_of_the_prefix() {
205        let dir = tempfile::tempdir().expect("temp dir");
206        let file = dir.path().join("dog.exe");
207        std::fs::write(&file, b"not really an exe").expect("write file");
208
209        let canonical = std::fs::canonicalize(&file).expect("canonicalize");
210        let rewritten = super::strip_verbatim_prefix(&canonical);
211        let shown = rewritten.display().to_string();
212
213        assert!(
214            !shown.starts_with(r"\\?\"),
215            "the path an operator will read still carries a verbatim prefix: {shown}"
216        );
217        assert!(
218            std::path::Path::new(&shown).is_file(),
219            "stripping the prefix must not break the path: {shown}"
220        );
221    }
222
223    #[cfg(not(windows))]
224    #[test]
225    fn a_unix_path_passes_through_untouched() {
226        let plain = std::path::Path::new("/tmp/flock.js");
227        assert_eq!(
228            super::strip_verbatim_prefix(plain).as_os_str(),
229            plain.as_os_str(),
230            "the non-Windows arm must be an identity"
231        );
232    }
233
234    use super::*;
235    use std::path::Path;
236
237    fn no_env(_: &str) -> Option<String> {
238        None
239    }
240
241    #[test]
242    fn default_layout_under_home_dir() {
243        let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
244        assert_eq!(p.home, Path::new("/home/ada/.shep"));
245        assert_eq!(p.daemon_config, Path::new("/home/ada/.shep/shep.toml"));
246        assert_eq!(p.dogs_config, Path::new("/home/ada/.shep/dogs.toml"));
247        assert_eq!(p.snapshot, Path::new("/home/ada/.shep/flock.json"));
248        assert_eq!(p.logs, Path::new("/home/ada/.shep/logs"));
249        assert_eq!(p.pids, Path::new("/home/ada/.shep/pids"));
250        assert_eq!(p.run, Path::new("/home/ada/.shep/run"));
251        assert_eq!(p.barks, Path::new("/home/ada/.shep/barks.jsonl"));
252        assert_eq!(p.kv, Path::new("/home/ada/.shep/kv.json"));
253        assert_eq!(p.overrides, Path::new("/home/ada/.shep/overrides.json"));
254        assert_eq!(p.secrets, Path::new("/home/ada/.shep/secrets.json"));
255        assert_eq!(
256            p.secrets_cache,
257            Path::new("/home/ada/.shep/secrets-cache.json")
258        );
259    }
260
261    /// Asserted per-platform rather than skipped on Windows: a silent
262    /// fallback to `run/shep.sock` there would leave a daemon bound to a
263    /// pipe and a client dialing a file that does not exist.
264    #[test]
265    fn the_control_address_is_a_socket_file_on_unix_and_a_pipe_name_on_windows() {
266        let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
267        #[cfg(unix)]
268        assert_eq!(p.socket, Path::new("/home/ada/.shep/run/shep.sock"));
269        #[cfg(windows)]
270        assert_eq!(
271            p.socket,
272            Path::new(r"\\.\pipe\shep-home-ada--shep-fd394cfc5c93ad12")
273        );
274        #[cfg(windows)]
275        assert_eq!(
276            p.socket,
277            Path::new(&p.pipe_name()),
278            "the resolved address and `pipe_name` must not drift"
279        );
280    }
281
282    #[test]
283    fn shep_home_env_overrides_root() {
284        let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
285        let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
286        assert_eq!(p.home, Path::new("/srv/shep"));
287        #[cfg(unix)]
288        assert_eq!(p.socket, Path::new("/srv/shep/run/shep.sock"));
289        #[cfg(windows)]
290        assert_eq!(
291            p.socket,
292            Path::new(r"\\.\pipe\shep-srv-shep-23b467803966a71a")
293        );
294    }
295
296    #[test]
297    fn pipe_name_is_per_home_and_sanitized() {
298        // Both homes come from the env, not the default join: its separator
299        // is host-specific and would give the digest a different value per
300        // platform.
301        let env = |key: &str| (key == "SHEP_HOME").then(|| "/home/ada/.shep".to_string());
302        let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
303        assert_eq!(
304            p.pipe_name(),
305            r"\\.\pipe\shep-home-ada--shep-626b4d544f86fe95"
306        );
307        let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
308        let q = ShepPaths::resolve(&env, Path::new("/home/ada"));
309        assert_eq!(q.pipe_name(), r"\\.\pipe\shep-srv-shep-23b467803966a71a");
310    }
311
312    /// The sanitizer is not injective: `\`, `:` and `-` all become `-`. A
313    /// collision would not error; it would refuse the second daemon as
314    /// already running.
315    #[test]
316    fn two_homes_that_sanitize_alike_get_distinct_pipe_names() {
317        let nested = |key: &str| (key == "SHEP_HOME").then(|| r"C:\a\b".to_string());
318        let dashed = |key: &str| (key == "SHEP_HOME").then(|| r"C:\a-b".to_string());
319        let n = ShepPaths::resolve(&nested, Path::new("/home/ada"));
320        let d = ShepPaths::resolve(&dashed, Path::new("/home/ada"));
321        assert!(
322            n.pipe_name().starts_with(r"\\.\pipe\shep-C--a-b-")
323                && d.pipe_name().starts_with(r"\\.\pipe\shep-C--a-b-"),
324            "the readable stem is what collides, and it stays readable: {} vs {}",
325            n.pipe_name(),
326            d.pipe_name()
327        );
328        assert_ne!(
329            n.pipe_name(),
330            d.pipe_name(),
331            "only the digest keeps two homes that sanitize alike off one pipe"
332        );
333    }
334}