1use std::path::{Path, PathBuf};
7
8#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ShepPaths {
54 pub home: PathBuf,
56 pub daemon_config: PathBuf,
58 pub dogs_config: PathBuf,
64 pub snapshot: PathBuf,
66 pub logs: PathBuf,
68 pub pids: PathBuf,
70 pub run: PathBuf,
72 pub socket: PathBuf,
82 pub barks: PathBuf,
84 pub kv: PathBuf,
86 pub overrides: PathBuf,
88}
89
90fn fnv1a64(bytes: &[u8]) -> u64 {
96 bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| {
97 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
98 })
99}
100
101impl ShepPaths {
102 #[must_use]
115 pub fn pipe_name(&self) -> String {
116 const MAX_STEM: usize = 64;
118
119 let home = self.home.to_string_lossy();
120 let sanitized: String = home
121 .chars()
122 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
123 .collect();
124 let trimmed = sanitized.trim_matches('-');
125 let stem = trimmed[..trimmed.len().min(MAX_STEM)].trim_end_matches('-');
127 let digest = fnv1a64(home.as_bytes());
128 format!(r"\\.\pipe\shep-{stem}-{digest:016x}")
129 }
130
131 #[must_use]
136 pub fn resolve(env: &dyn Fn(&str) -> Option<String>, home_dir: &Path) -> Self {
137 let home = env("SHEP_HOME")
138 .map(PathBuf::from)
139 .unwrap_or_else(|| home_dir.join(".shep"));
140 let run = home.join("run");
141 #[cfg_attr(not(windows), allow(unused_mut))]
144 let mut paths = Self {
145 daemon_config: home.join("shep.toml"),
146 dogs_config: home.join("dogs.toml"),
147 snapshot: home.join("flock.json"),
148 logs: home.join("logs"),
149 pids: home.join("pids"),
150 socket: run.join("shep.sock"),
151 barks: home.join("barks.jsonl"),
152 kv: home.join("kv.json"),
153 overrides: home.join("overrides.json"),
154 run,
155 home,
156 };
157 #[cfg(windows)]
160 {
161 paths.socket = PathBuf::from(paths.pipe_name());
162 }
163 paths
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 #[cfg(windows)]
172 #[test]
173 fn a_verbatim_prefix_is_stripped() {
174 let rewritten = super::strip_verbatim_prefix(std::path::Path::new(r"\\?\C:\tmp\flock.js"));
175 assert_eq!(
176 rewritten.as_os_str(),
177 std::ffi::OsStr::new(r"C:\tmp\flock.js"),
178 "node reads the leading `\\\\` as a UNC share and lstats `C:`, so \
179 the verbatim prefix must not reach it"
180 );
181
182 let plain = std::path::Path::new(r"C:\tmp\flock.js");
183 assert_eq!(
184 super::strip_verbatim_prefix(plain).as_os_str(),
185 plain.as_os_str(),
186 "a path with no verbatim prefix must pass through untouched"
187 );
188 }
189
190 #[cfg(windows)]
194 #[test]
195 fn a_real_canonicalized_path_comes_back_free_of_the_prefix() {
196 let dir = tempfile::tempdir().expect("temp dir");
197 let file = dir.path().join("dog.exe");
198 std::fs::write(&file, b"not really an exe").expect("write file");
199
200 let canonical = std::fs::canonicalize(&file).expect("canonicalize");
201 let rewritten = super::strip_verbatim_prefix(&canonical);
202 let shown = rewritten.display().to_string();
203
204 assert!(
205 !shown.starts_with(r"\\?\"),
206 "the path an operator will read still carries a verbatim prefix: {shown}"
207 );
208 assert!(
209 std::path::Path::new(&shown).is_file(),
210 "stripping the prefix must not break the path: {shown}"
211 );
212 }
213
214 #[cfg(not(windows))]
215 #[test]
216 fn a_unix_path_passes_through_untouched() {
217 let plain = std::path::Path::new("/tmp/flock.js");
218 assert_eq!(
219 super::strip_verbatim_prefix(plain).as_os_str(),
220 plain.as_os_str(),
221 "the non-Windows arm must be an identity"
222 );
223 }
224
225 use super::*;
226 use std::path::Path;
227
228 fn no_env(_: &str) -> Option<String> {
229 None
230 }
231
232 #[test]
233 fn default_layout_under_home_dir() {
234 let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
235 assert_eq!(p.home, Path::new("/home/ada/.shep"));
236 assert_eq!(p.daemon_config, Path::new("/home/ada/.shep/shep.toml"));
237 assert_eq!(p.dogs_config, Path::new("/home/ada/.shep/dogs.toml"));
238 assert_eq!(p.snapshot, Path::new("/home/ada/.shep/flock.json"));
239 assert_eq!(p.logs, Path::new("/home/ada/.shep/logs"));
240 assert_eq!(p.pids, Path::new("/home/ada/.shep/pids"));
241 assert_eq!(p.run, Path::new("/home/ada/.shep/run"));
242 assert_eq!(p.barks, Path::new("/home/ada/.shep/barks.jsonl"));
243 assert_eq!(p.kv, Path::new("/home/ada/.shep/kv.json"));
244 assert_eq!(p.overrides, Path::new("/home/ada/.shep/overrides.json"));
245 }
246
247 #[test]
251 fn the_control_address_is_a_socket_file_on_unix_and_a_pipe_name_on_windows() {
252 let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
253 #[cfg(unix)]
254 assert_eq!(p.socket, Path::new("/home/ada/.shep/run/shep.sock"));
255 #[cfg(windows)]
256 assert_eq!(
257 p.socket,
258 Path::new(r"\\.\pipe\shep-home-ada--shep-fd394cfc5c93ad12")
259 );
260 #[cfg(windows)]
261 assert_eq!(
262 p.socket,
263 Path::new(&p.pipe_name()),
264 "the resolved address and `pipe_name` must not drift"
265 );
266 }
267
268 #[test]
269 fn shep_home_env_overrides_root() {
270 let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
271 let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
272 assert_eq!(p.home, Path::new("/srv/shep"));
273 #[cfg(unix)]
274 assert_eq!(p.socket, Path::new("/srv/shep/run/shep.sock"));
275 #[cfg(windows)]
276 assert_eq!(
277 p.socket,
278 Path::new(r"\\.\pipe\shep-srv-shep-23b467803966a71a")
279 );
280 }
281
282 #[test]
283 fn pipe_name_is_per_home_and_sanitized() {
284 let env = |key: &str| (key == "SHEP_HOME").then(|| "/home/ada/.shep".to_string());
288 let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
289 assert_eq!(
290 p.pipe_name(),
291 r"\\.\pipe\shep-home-ada--shep-626b4d544f86fe95"
292 );
293 let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
294 let q = ShepPaths::resolve(&env, Path::new("/home/ada"));
295 assert_eq!(q.pipe_name(), r"\\.\pipe\shep-srv-shep-23b467803966a71a");
296 }
297
298 #[test]
302 fn two_homes_that_sanitize_alike_get_distinct_pipe_names() {
303 let nested = |key: &str| (key == "SHEP_HOME").then(|| r"C:\a\b".to_string());
304 let dashed = |key: &str| (key == "SHEP_HOME").then(|| r"C:\a-b".to_string());
305 let n = ShepPaths::resolve(&nested, Path::new("/home/ada"));
306 let d = ShepPaths::resolve(&dashed, Path::new("/home/ada"));
307 assert!(
308 n.pipe_name().starts_with(r"\\.\pipe\shep-C--a-b-")
309 && d.pipe_name().starts_with(r"\\.\pipe\shep-C--a-b-"),
310 "the readable stem is what collides, and it stays readable: {} vs {}",
311 n.pipe_name(),
312 d.pipe_name()
313 );
314 assert_ne!(
315 n.pipe_name(),
316 d.pipe_name(),
317 "only the digest keeps two homes that sanitize alike off one pipe"
318 );
319 }
320}