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 pub secrets: PathBuf,
90 pub secrets_cache: PathBuf,
95}
96
97fn 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 #[must_use]
122 pub fn pipe_name(&self) -> String {
123 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 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 #[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 #[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 #[cfg(windows)]
169 {
170 paths.socket = PathBuf::from(paths.pipe_name());
171 }
172 paths
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 #[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 #[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 #[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 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 #[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}