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/// `std::fs::canonicalize` returns a verbatim path on Windows, so a binary at
11/// `C:\tools\dog.exe` comes back as `\\?\C:\tools\dog.exe`. That form is
12/// correct and every Win32 call accepts it, which is exactly why it leaks
13/// quietly: nothing inside shep breaks, and it surfaces only once the path
14/// reaches something that is not Win32. Two such places are already known.
15/// Node's `require` reads the leading `\\` as a UNC share and fails on `C:`.
16/// And `shep adopt` records the vetted binary in `shep.toml`, where the prefix
17/// is simply noise in a file an operator edits by hand.
18///
19/// So this is for paths that LEAVE shep: written to config, shown to an
20/// operator, or handed to another program. Paths that stay inside and are
21/// compared against each other must not use it. `serve`'s docroot containment
22/// check is the case that matters, where both sides being canonical is the
23/// security property, and rewriting one side would weaken it.
24///
25/// Only `\\?\C:\` is unwrapped, because that is the one shape `canonicalize`
26/// produces for a local file. A verbatim UNC path (`\\?\UNC\server\share`)
27/// is left alone: no host here can mount a share to test that branch, and an
28/// unexercised guess is worth less than a documented gap.
29///
30/// **A path long enough to need the prefix is out of scope.** Above `MAX_PATH`
31/// the prefix is load-bearing rather than decorative, and stripping it can
32/// produce a path that no longer opens. Nothing in shep's own layout comes
33/// close, and the alternative is a conditional rule whose behavior changes at
34/// a length nobody can see, so the simple rule is the one kept.
35#[cfg(windows)]
36#[must_use]
37pub fn strip_verbatim_prefix(path: &Path) -> std::borrow::Cow<'_, Path> {
38    use std::path::{Component, Prefix};
39
40    let mut components = path.components();
41    let Some(Component::Prefix(prefix)) = components.next() else {
42        return std::borrow::Cow::Borrowed(path);
43    };
44    let Prefix::VerbatimDisk(letter) = prefix.kind() else {
45        return std::borrow::Cow::Borrowed(path);
46    };
47
48    let mut rebuilt = PathBuf::from(format!("{}:\\", char::from(letter)));
49    rebuilt.extend(components.filter(|part| !matches!(part, Component::RootDir)));
50    std::borrow::Cow::Owned(rebuilt)
51}
52
53/// Passes the path through: only Windows' `canonicalize` prefixes its output
54///
55/// See the Windows sibling for what this exists to undo.
56#[cfg(not(windows))]
57#[must_use]
58pub fn strip_verbatim_prefix(path: &Path) -> std::borrow::Cow<'_, Path> {
59    std::borrow::Cow::Borrowed(path)
60}
61
62/// Resolved filesystem layout for one shep home
63///
64/// All paths are derived from `$SHEP_HOME` (default `<home>/.shep`); nothing
65/// here touches the filesystem. The root itself is created by the CLI's own
66/// `ensure_home`, for the commands that need it before any daemon exists
67/// (`startup` above all), and everything under it by
68/// `shep_daemon::boot::init_dirs` on each boot.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ShepPaths {
71    /// Root: `$SHEP_HOME`
72    pub home: PathBuf,
73    /// Daemon config: `shep.toml`
74    pub daemon_config: PathBuf,
75    /// A dog's own settings: `dogs.toml`
76    ///
77    /// Separate from [`Self::daemon_config`] rather than a section inside
78    /// it, so lookout can write a dog's config without writing into the
79    /// daemon's own hand-authored file.
80    pub dogs_config: PathBuf,
81    /// Flock snapshot (muster roll): `flock.json`
82    pub snapshot: PathBuf,
83    /// Log directory
84    pub logs: PathBuf,
85    /// Pid-file directory
86    pub pids: PathBuf,
87    /// Runtime dir (sockets; created 0700)
88    pub run: PathBuf,
89    /// The control address the client dials and the daemon answers on.
90    ///
91    /// **Two different kinds of thing behind one field, on purpose.** On
92    /// unix it is a filesystem path, `run/shep.sock`, and a real AF_UNIX
93    /// socket file lives there. On Windows it is [`Self::pipe_name`] — a
94    /// named pipe's `\\.\pipe\...` name, which is path-*shaped* but names an
95    /// object in the kernel's pipe namespace rather than a file on any
96    /// volume.
97    ///
98    /// One field rather than two because every consumer in the workspace
99    /// treats this as an opaque address it hands to `Client::connect`, and a
100    /// second field would make all of them choose. The one place the
101    /// difference is load-bearing is a caller that treats this as a *file* —
102    /// `shep-cli`'s `wait_for_socket_to_disappear` is the only one, and it
103    /// carries its own Windows arm because a pipe has no directory entry to
104    /// watch: it stops existing when its last handle closes, so "has the
105    /// daemon gone" is a connect attempt there, not a `Path::exists`.
106    ///
107    /// A corollary worth stating because it silently breaks otherwise:
108    /// `socket.parent()` is `$SHEP_HOME/run` on unix and the meaningless
109    /// `\\.\pipe` on Windows. Nothing may derive a directory from this field.
110    pub socket: PathBuf,
111    /// Bark history ring: `barks.jsonl`
112    pub barks: PathBuf,
113    /// Key/value store: `kv.json`
114    pub kv: PathBuf,
115    /// Operator override store: `overrides.json`
116    pub overrides: PathBuf,
117}
118
119/// FNV-1a, 64-bit, over `bytes`
120///
121/// Hand-rolled rather than reached for from `std`: [`std::hash::DefaultHasher`]
122/// does not promise a stable value across toolchains, and the daemon and a
123/// client built separately have to derive one pipe name and agree on it.
124fn fnv1a64(bytes: &[u8]) -> u64 {
125    bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| {
126        (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
127    })
128}
129
130impl ShepPaths {
131    /// Windows named-pipe identity for this home:
132    /// `\\.\pipe\shep-<sanitized>-<digest>`
133    ///
134    /// The readable half is the home path with every non-alphanumeric
135    /// character collapsed to `-`, capped, so an operator reading a pipe name
136    /// can tell which home it belongs to. **That half alone does not identify
137    /// a home**: `\`, `:`, `.`, `_` and a literal `-` all become `-`, so
138    /// `C:\a\b` and `C:\a-b` sanitize to one string. The pipe namespace is
139    /// machine-global and [`crate::transport::Listener::bind`] asks for
140    /// `first_pipe_instance`, so a collision does not surface as an error: the
141    /// second home's daemon is refused as already running, and that home's CLI
142    /// then drives the first home's flock. No handshake field carries a home,
143    /// so nothing downstream would catch it.
144    ///
145    /// The appended digest of the full home path is what makes the name
146    /// distinct. Changing this derivation is a breaking change for any
147    /// already-running daemon: it stays bound under a name a client built
148    /// afterward would never dial.
149    #[must_use]
150    pub fn pipe_name(&self) -> String {
151        // Bounds the readable half; a pipe name may be 256 characters.
152        const MAX_STEM: usize = 64;
153
154        let home = self.home.to_string_lossy();
155        let sanitized: String = home
156            .chars()
157            .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
158            .collect();
159        let trimmed = sanitized.trim_matches('-');
160        // Every character above is ASCII, so this cut cannot split one.
161        let stem = trimmed[..trimmed.len().min(MAX_STEM)].trim_end_matches('-');
162        let digest = fnv1a64(home.as_bytes());
163        format!(r"\\.\pipe\shep-{stem}-{digest:016x}")
164    }
165
166    /// Resolves the layout from an environment lookup and the user's home dir
167    ///
168    /// [`Self::socket`] resolves per-platform — a socket file under `run/` on
169    /// unix, a `\\.\pipe\...` name on Windows — for the reason that field's
170    /// own doc gives. Everything else is identical on both.
171    #[must_use]
172    pub fn resolve(env: &dyn Fn(&str) -> Option<String>, home_dir: &Path) -> Self {
173        let home = env("SHEP_HOME")
174            .map(PathBuf::from)
175            .unwrap_or_else(|| home_dir.join(".shep"));
176        let run = home.join("run");
177        // `mut` is read only by the `cfg(windows)` block below; on unix the
178        // value is returned exactly as built.
179        #[cfg_attr(not(windows), allow(unused_mut))]
180        let mut paths = Self {
181            daemon_config: home.join("shep.toml"),
182            dogs_config: home.join("dogs.toml"),
183            snapshot: home.join("flock.json"),
184            logs: home.join("logs"),
185            pids: home.join("pids"),
186            socket: run.join("shep.sock"),
187            barks: home.join("barks.jsonl"),
188            kv: home.join("kv.json"),
189            overrides: home.join("overrides.json"),
190            run,
191            home,
192        };
193        // Computed from the already-built value rather than inline above,
194        // because `pipe_name` reads `self.home` and the struct is what owns
195        // that derivation — duplicating the sanitizer here is exactly how
196        // the two would drift.
197        #[cfg(windows)]
198        {
199            paths.socket = PathBuf::from(paths.pipe_name());
200        }
201        paths
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    /// Pins the strip directly, because no end-to-end case can. Node resolves
208    /// a `\\?\` path on some versions and not others, so the `.js` flockfile
209    /// cases passed on the development machine both before this existed and
210    /// after, while failing on the CI runner both times. Asserting on the
211    /// rewritten path is the part that holds either way.
212    #[cfg(windows)]
213    #[test]
214    fn a_verbatim_prefix_is_stripped() {
215        let rewritten = super::strip_verbatim_prefix(std::path::Path::new(r"\\?\C:\tmp\flock.js"));
216        assert_eq!(
217            rewritten.as_os_str(),
218            std::ffi::OsStr::new(r"C:\tmp\flock.js"),
219            "node reads the leading `\\\\` as a UNC share and lstats `C:`, so \
220             the verbatim prefix must not reach it"
221        );
222
223        let plain = std::path::Path::new(r"C:\tmp\flock.js");
224        assert_eq!(
225            super::strip_verbatim_prefix(plain).as_os_str(),
226            plain.as_os_str(),
227            "a path with no verbatim prefix must pass through untouched"
228        );
229    }
230
231    /// Guards the assumption the strip rests on: that `canonicalize` really
232    /// does hand back a prefixed path, and that the rewrite clears it without
233    /// breaking what it points at. If a future Windows or std stops adding
234    /// the prefix, this stays green and the strip becomes a no-op rather
235    /// than a wrong answer.
236    #[cfg(windows)]
237    #[test]
238    fn a_real_canonicalized_path_comes_back_free_of_the_prefix() {
239        let dir = tempfile::tempdir().expect("temp dir");
240        let file = dir.path().join("dog.exe");
241        std::fs::write(&file, b"not really an exe").expect("write file");
242
243        let canonical = std::fs::canonicalize(&file).expect("canonicalize");
244        let rewritten = super::strip_verbatim_prefix(&canonical);
245        let shown = rewritten.display().to_string();
246
247        assert!(
248            !shown.starts_with(r"\\?\"),
249            "the path an operator will read still carries a verbatim prefix: {shown}"
250        );
251        assert!(
252            std::path::Path::new(&shown).is_file(),
253            "stripping the prefix must not break the path: {shown}"
254        );
255    }
256
257    /// The unix build has nothing to strip, and the helper exists there only
258    /// so call sites do not each carry a `cfg`. Pinned so it stays that way.
259    #[cfg(not(windows))]
260    #[test]
261    fn a_unix_path_passes_through_untouched() {
262        let plain = std::path::Path::new("/tmp/flock.js");
263        assert_eq!(
264            super::strip_verbatim_prefix(plain).as_os_str(),
265            plain.as_os_str(),
266            "the non-Windows arm must be an identity"
267        );
268    }
269
270    use super::*;
271    use std::path::Path;
272
273    fn no_env(_: &str) -> Option<String> {
274        None
275    }
276
277    #[test]
278    fn default_layout_under_home_dir() {
279        let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
280        assert_eq!(p.home, Path::new("/home/ada/.shep"));
281        assert_eq!(p.daemon_config, Path::new("/home/ada/.shep/shep.toml"));
282        assert_eq!(p.dogs_config, Path::new("/home/ada/.shep/dogs.toml"));
283        assert_eq!(p.snapshot, Path::new("/home/ada/.shep/flock.json"));
284        assert_eq!(p.logs, Path::new("/home/ada/.shep/logs"));
285        assert_eq!(p.pids, Path::new("/home/ada/.shep/pids"));
286        assert_eq!(p.run, Path::new("/home/ada/.shep/run"));
287        assert_eq!(p.barks, Path::new("/home/ada/.shep/barks.jsonl"));
288        assert_eq!(p.kv, Path::new("/home/ada/.shep/kv.json"));
289        assert_eq!(p.overrides, Path::new("/home/ada/.shep/overrides.json"));
290    }
291
292    /// The one field that is not the same kind of thing on both platforms —
293    /// see [`ShepPaths::socket`]'s own doc. Asserted per-platform rather
294    /// than skipped on Windows, because "the socket resolves to the pipe
295    /// name" IS the Windows transport's identity and a silent fallback to
296    /// `run/shep.sock` there would produce a daemon that binds a pipe and a
297    /// client that dials a file that does not exist.
298    #[test]
299    fn the_control_address_is_a_socket_file_on_unix_and_a_pipe_name_on_windows() {
300        let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
301        #[cfg(unix)]
302        assert_eq!(p.socket, Path::new("/home/ada/.shep/run/shep.sock"));
303        #[cfg(windows)]
304        assert_eq!(
305            p.socket,
306            Path::new(r"\\.\pipe\shep-home-ada--shep-fd394cfc5c93ad12")
307        );
308        #[cfg(windows)]
309        assert_eq!(
310            p.socket,
311            Path::new(&p.pipe_name()),
312            "the resolved address and `pipe_name` must not drift"
313        );
314    }
315
316    #[test]
317    fn shep_home_env_overrides_root() {
318        let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
319        let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
320        assert_eq!(p.home, Path::new("/srv/shep"));
321        #[cfg(unix)]
322        assert_eq!(p.socket, Path::new("/srv/shep/run/shep.sock"));
323        #[cfg(windows)]
324        assert_eq!(
325            p.socket,
326            Path::new(r"\\.\pipe\shep-srv-shep-23b467803966a71a")
327        );
328    }
329
330    #[test]
331    fn pipe_name_is_per_home_and_sanitized() {
332        // Windows transport identity (spec §6): derived from SHEP_HOME so
333        // two homes never share a pipe; non-alphanumerics collapse to '-',
334        // then a digest of the whole home path. Both homes come from the env
335        // rather than the default join, whose separator is the host's and
336        // would give the digest a different value per platform.
337        let env = |key: &str| (key == "SHEP_HOME").then(|| "/home/ada/.shep".to_string());
338        let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
339        assert_eq!(
340            p.pipe_name(),
341            r"\\.\pipe\shep-home-ada--shep-626b4d544f86fe95"
342        );
343        let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
344        let q = ShepPaths::resolve(&env, Path::new("/home/ada"));
345        assert_eq!(q.pipe_name(), r"\\.\pipe\shep-srv-shep-23b467803966a71a");
346    }
347
348    /// The sanitizer is not injective (`\`, `:` and a literal `-` all become
349    /// `-`), and a shared name is the one failure that reaches nobody: the
350    /// second daemon is refused as already running and its CLI then drives the
351    /// first home's flock in silence.
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}