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/// How an operator on this platform spells the variable behind
83/// [`user_home`], for a refusal that has to say what to set.
84#[cfg(not(windows))]
85pub(crate) const HOME_DIR_VAR: &str = "$HOME";
86
87/// Names `%USERPROFILE%` rather than `HOME`: although [`user_home`] reads
88/// `HOME` first, a stock Windows session sets none, so `%USERPROFILE%` is
89/// the first of the three that answers.
90#[cfg(windows)]
91pub(crate) const HOME_DIR_VAR: &str = "%USERPROFILE%";
92
93/// How an operator on this platform spells `SHEP_HOME`, for a refusal that
94/// has to say what to set.
95#[cfg(not(windows))]
96pub(crate) const SHEP_HOME_VAR: &str = "$SHEP_HOME";
97
98/// How an operator on this platform spells `SHEP_HOME`, for a refusal that
99/// has to say what to set.
100#[cfg(windows)]
101pub(crate) const SHEP_HOME_VAR: &str = "%SHEP_HOME%";
102
103/// The directory a shep home defaults to, under the user's own home.
104const DEFAULT_HOME_DIR: &str = ".shep";
105
106/// The shep home directory `$SHEP_HOME` names, or the default under
107/// `home_dir`
108///
109/// `None` only when nothing names a `$SHEP_HOME` and `home_dir` is `None`
110/// too, which is the same condition that leaves a `~/` path unexpandable.
111///
112/// Unlike [`user_home`], an empty `$SHEP_HOME` is taken at its word rather
113/// than read as unset, which is what [`ShepPaths::resolve`] has always done.
114/// No operator reaches that: the CLI refuses an empty `--home` or
115/// `$SHEP_HOME` before either function runs. It matters to a library caller
116/// passing its own lookup, who gets one answer from here and from the
117/// layout rather than two.
118#[must_use]
119pub fn shep_home(env: &dyn Fn(&str) -> Option<String>, home_dir: Option<&Path>) -> Option<PathBuf> {
120    match home_dir {
121        Some(dir) => Some(home_under(env, dir)),
122        None => env("SHEP_HOME").map(PathBuf::from),
123    }
124}
125
126/// [`shep_home`] for a caller that has a home directory, so the answer is
127/// total.
128///
129/// The rule lives here rather than in [`shep_home`] so that
130/// [`ShepPaths::resolve`], which always has one, reaches it without an
131/// `Option` it would have to unwrap through a branch that can never fire.
132fn home_under(env: &dyn Fn(&str) -> Option<String>, home_dir: &Path) -> PathBuf {
133    env("SHEP_HOME")
134        .map(PathBuf::from)
135        .unwrap_or_else(|| home_dir.join(DEFAULT_HOME_DIR))
136}
137
138/// Resolved filesystem layout for one shep home
139///
140/// All paths are derived from `$SHEP_HOME` (default `<home>/.shep`); nothing
141/// here touches the filesystem. The root itself is created by the CLI's own
142/// `ensure_home`, for the commands that need it before any daemon exists
143/// (`startup` above all), and everything under it by
144/// `shep_daemon::boot::init_dirs` on each boot.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ShepPaths {
147    /// Root: `$SHEP_HOME`
148    pub home: PathBuf,
149    /// Daemon config: `shep.toml`
150    pub daemon_config: PathBuf,
151    /// A dog's own settings: `dogs.toml`
152    ///
153    /// Separate from [`Self::daemon_config`] rather than a section inside
154    /// it, so lookout can write a dog's config without writing into the
155    /// daemon's own hand-authored file.
156    pub dogs_config: PathBuf,
157    /// Flock snapshot (muster roll): `flock.json`
158    pub snapshot: PathBuf,
159    /// Log directory
160    pub logs: PathBuf,
161    /// Pid-file directory
162    pub pids: PathBuf,
163    /// Runtime dir (sockets; created 0700)
164    pub run: PathBuf,
165    /// The control address the client dials and the daemon answers on.
166    ///
167    /// Unix: a filesystem path, `run/shep.sock`, naming a real AF_UNIX
168    /// socket file. Windows: [`Self::pipe_name`], path-shaped but naming an
169    /// object in the kernel's pipe namespace, not a file on any volume.
170    /// Never derive a directory from this field: `socket.parent()` is
171    /// meaningless on Windows. A pipe has no directory entry to watch, so
172    /// "has the daemon gone" needs a connect attempt there, not
173    /// `Path::exists`.
174    pub socket: PathBuf,
175    /// Bark history ring: `barks.jsonl`
176    pub barks: PathBuf,
177    /// Key/value store: `kv.json`
178    pub kv: PathBuf,
179    /// Operator override store: `overrides.json`
180    pub overrides: PathBuf,
181    /// Secret store: `secrets.json`
182    pub secrets: PathBuf,
183    /// Cached provider values: `secrets-cache.json`
184    ///
185    /// Derived and safe to delete, unlike [`Self::secrets`]: a provider dog
186    /// rewrites it on its next push.
187    pub secrets_cache: PathBuf,
188}
189
190/// FNV-1a, 64-bit, over `bytes`
191///
192/// Hand-rolled rather than reached for from `std`: [`std::hash::DefaultHasher`]
193/// does not promise a stable value across toolchains, and the daemon and a
194/// client built separately have to derive one pipe name and agree on it.
195fn fnv1a64(bytes: &[u8]) -> u64 {
196    bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| {
197        (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
198    })
199}
200
201impl ShepPaths {
202    /// Windows named-pipe identity for this home:
203    /// `\\.\pipe\shep-<sanitized>-<digest>`
204    ///
205    /// The sanitized stem is not unique alone: `\`, `:`, `.`, `_` and a
206    /// literal `-` all collapse to `-`, so `C:\a\b` and `C:\a-b` sanitize to
207    /// one string. The digest of the full home path is what keeps two homes
208    /// distinct; without it a collision would not error, it would refuse the
209    /// second daemon as already running and let its CLI drive the first
210    /// home's flock.
211    ///
212    /// Changing this derivation breaks any already-running daemon: it stays
213    /// bound under a name a client built afterward would never dial.
214    #[must_use]
215    pub fn pipe_name(&self) -> String {
216        // Bounds the readable half; a pipe name may be 256 characters.
217        const MAX_STEM: usize = 64;
218
219        let home = self.home.to_string_lossy();
220        let sanitized: String = home
221            .chars()
222            .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
223            .collect();
224        let trimmed = sanitized.trim_matches('-');
225        // Every character above is ASCII, so this cut cannot split one.
226        let stem = trimmed[..trimmed.len().min(MAX_STEM)].trim_end_matches('-');
227        let digest = fnv1a64(home.as_bytes());
228        format!(r"\\.\pipe\shep-{stem}-{digest:016x}")
229    }
230
231    /// Resolves the layout from an environment lookup and the user's home dir
232    ///
233    /// [`Self::socket`] resolves per-platform: a socket file under `run/` on
234    /// unix, [`Self::pipe_name`] on Windows. Everything else is identical.
235    ///
236    /// # Why `String` and not `OsString`
237    ///
238    /// [`user_home`] takes an `OsString` lookup and this takes a `String`
239    /// one, which reads like an oversight and is not. A home path does not
240    /// stay a path: it reaches the `{{SHEP_HOME}}` template, [`Self::pipe_name`]
241    /// and every log path on the wire as text, and each of those conversions
242    /// is lossy. Widening this signature would move the loss rather than
243    /// remove it, and it would break every caller of a published crate to do
244    /// so.
245    ///
246    /// The CLI refuses a home that is not valid UTF-8 instead, at the one
247    /// door an operator can name one through, so nothing lossy reaches here.
248    /// A library caller supplying its own lookup has already chosen its
249    /// encoding by building a `String`.
250    #[must_use]
251    pub fn resolve(env: &dyn Fn(&str) -> Option<String>, home_dir: &Path) -> Self {
252        let home = home_under(env, home_dir);
253        let run = home.join("run");
254        // `mut` is read only by the `cfg(windows)` block below; on unix the
255        // value is returned exactly as built.
256        #[cfg_attr(not(windows), allow(unused_mut))]
257        let mut paths = Self {
258            daemon_config: home.join("shep.toml"),
259            dogs_config: home.join("dogs.toml"),
260            snapshot: home.join("flock.json"),
261            logs: home.join("logs"),
262            pids: home.join("pids"),
263            socket: run.join("shep.sock"),
264            barks: home.join("barks.jsonl"),
265            kv: home.join("kv.json"),
266            overrides: home.join("overrides.json"),
267            secrets: home.join("secrets.json"),
268            secrets_cache: home.join("secrets-cache.json"),
269            run,
270            home,
271        };
272        // Computed here, not inlined above: `pipe_name` reads `self.home`,
273        // and duplicating the sanitizer here would let the two drift.
274        #[cfg(windows)]
275        {
276            paths.socket = PathBuf::from(paths.pipe_name());
277        }
278        paths
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    /// The one rule `ShepPaths::resolve` and the `{{SHEP_HOME}}` token both
285    /// read, so a value rendered into a log path names the directory the
286    /// rest of the layout was built under.
287    #[test]
288    fn shep_home_prefers_the_variable_and_falls_back_to_the_default() {
289        use std::path::{Path, PathBuf};
290
291        let named = |_: &str| Some("/srv/shep".to_string());
292        let unset = |_: &str| None;
293        let ada = Path::new("/home/ada");
294
295        assert_eq!(
296            super::shep_home(&named, Some(ada)),
297            Some(PathBuf::from("/srv/shep")),
298            "the variable wins over the default"
299        );
300        assert_eq!(
301            super::shep_home(&named, None),
302            Some(PathBuf::from("/srv/shep")),
303            "and needs no home directory of its own"
304        );
305        assert_eq!(
306            super::shep_home(&unset, Some(ada)),
307            Some(ada.join(".shep")),
308            "the default hangs off the user's home"
309        );
310        assert_eq!(
311            super::shep_home(&unset, None),
312            None,
313            "with neither, there is nothing to name"
314        );
315        // The one behaviour that differs from `user_home`, which reads an
316        // empty value as unset. Untested, a regression that filtered
317        // `Some("")` the same way would leave the token and the layout
318        // pointing at different directories and nothing would say so.
319        assert_eq!(
320            super::shep_home(&|_| Some(String::new()), Some(ada)),
321            Some(PathBuf::new()),
322            "an empty value is taken at its word rather than read as unset"
323        );
324        assert_eq!(
325            super::ShepPaths::resolve(&named, ada).home,
326            super::shep_home(&named, Some(ada)).expect("a home directory was given"),
327            "and the layout is built from the same answer"
328        );
329    }
330
331    /// Unit-level because no end-to-end case can pin this reliably: Node's
332    /// handling of a `\\?\` path differs by version.
333    #[cfg(windows)]
334    #[test]
335    fn a_verbatim_prefix_is_stripped() {
336        let rewritten = super::strip_verbatim_prefix(std::path::Path::new(r"\\?\C:\tmp\flock.js"));
337        assert_eq!(
338            rewritten.as_os_str(),
339            std::ffi::OsStr::new(r"C:\tmp\flock.js"),
340            "node reads the leading `\\\\` as a UNC share and lstats `C:`, so \
341             the verbatim prefix must not reach it"
342        );
343
344        let plain = std::path::Path::new(r"C:\tmp\flock.js");
345        assert_eq!(
346            super::strip_verbatim_prefix(plain).as_os_str(),
347            plain.as_os_str(),
348            "a path with no verbatim prefix must pass through untouched"
349        );
350    }
351
352    /// Guards the assumption that `canonicalize` really prefixes the path.
353    /// If a future Windows or std stops adding it, this stays green and the
354    /// strip becomes a no-op rather than a wrong answer.
355    #[cfg(windows)]
356    #[test]
357    fn a_real_canonicalized_path_comes_back_free_of_the_prefix() {
358        let dir = tempfile::tempdir().expect("temp dir");
359        let file = dir.path().join("dog.exe");
360        std::fs::write(&file, b"not really an exe").expect("write file");
361
362        let canonical = std::fs::canonicalize(&file).expect("canonicalize");
363        let rewritten = super::strip_verbatim_prefix(&canonical);
364        let shown = rewritten.display().to_string();
365
366        assert!(
367            !shown.starts_with(r"\\?\"),
368            "the path an operator will read still carries a verbatim prefix: {shown}"
369        );
370        assert!(
371            std::path::Path::new(&shown).is_file(),
372            "stripping the prefix must not break the path: {shown}"
373        );
374    }
375
376    #[cfg(not(windows))]
377    #[test]
378    fn a_unix_path_passes_through_untouched() {
379        let plain = std::path::Path::new("/tmp/flock.js");
380        assert_eq!(
381            super::strip_verbatim_prefix(plain).as_os_str(),
382            plain.as_os_str(),
383            "the non-Windows arm must be an identity"
384        );
385    }
386
387    use super::*;
388    use std::path::Path;
389
390    fn no_env(_: &str) -> Option<String> {
391        None
392    }
393
394    #[test]
395    fn default_layout_under_home_dir() {
396        let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
397        assert_eq!(p.home, Path::new("/home/ada/.shep"));
398        assert_eq!(p.daemon_config, Path::new("/home/ada/.shep/shep.toml"));
399        assert_eq!(p.dogs_config, Path::new("/home/ada/.shep/dogs.toml"));
400        assert_eq!(p.snapshot, Path::new("/home/ada/.shep/flock.json"));
401        assert_eq!(p.logs, Path::new("/home/ada/.shep/logs"));
402        assert_eq!(p.pids, Path::new("/home/ada/.shep/pids"));
403        assert_eq!(p.run, Path::new("/home/ada/.shep/run"));
404        assert_eq!(p.barks, Path::new("/home/ada/.shep/barks.jsonl"));
405        assert_eq!(p.kv, Path::new("/home/ada/.shep/kv.json"));
406        assert_eq!(p.overrides, Path::new("/home/ada/.shep/overrides.json"));
407        assert_eq!(p.secrets, Path::new("/home/ada/.shep/secrets.json"));
408        assert_eq!(
409            p.secrets_cache,
410            Path::new("/home/ada/.shep/secrets-cache.json")
411        );
412    }
413
414    /// Asserted per-platform rather than skipped on Windows: a silent
415    /// fallback to `run/shep.sock` there would leave a daemon bound to a
416    /// pipe and a client dialing a file that does not exist.
417    #[test]
418    fn the_control_address_is_a_socket_file_on_unix_and_a_pipe_name_on_windows() {
419        let p = ShepPaths::resolve(&no_env, Path::new("/home/ada"));
420        #[cfg(unix)]
421        assert_eq!(p.socket, Path::new("/home/ada/.shep/run/shep.sock"));
422        #[cfg(windows)]
423        assert_eq!(
424            p.socket,
425            Path::new(r"\\.\pipe\shep-home-ada--shep-fd394cfc5c93ad12")
426        );
427        #[cfg(windows)]
428        assert_eq!(
429            p.socket,
430            Path::new(&p.pipe_name()),
431            "the resolved address and `pipe_name` must not drift"
432        );
433    }
434
435    #[test]
436    fn shep_home_env_overrides_root() {
437        let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
438        let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
439        assert_eq!(p.home, Path::new("/srv/shep"));
440        #[cfg(unix)]
441        assert_eq!(p.socket, Path::new("/srv/shep/run/shep.sock"));
442        #[cfg(windows)]
443        assert_eq!(
444            p.socket,
445            Path::new(r"\\.\pipe\shep-srv-shep-23b467803966a71a")
446        );
447    }
448
449    #[test]
450    fn pipe_name_is_per_home_and_sanitized() {
451        // Both homes come from the env, not the default join: its separator
452        // is host-specific and would give the digest a different value per
453        // platform.
454        let env = |key: &str| (key == "SHEP_HOME").then(|| "/home/ada/.shep".to_string());
455        let p = ShepPaths::resolve(&env, Path::new("/home/ada"));
456        assert_eq!(
457            p.pipe_name(),
458            r"\\.\pipe\shep-home-ada--shep-626b4d544f86fe95"
459        );
460        let env = |key: &str| (key == "SHEP_HOME").then(|| "/srv/shep".to_string());
461        let q = ShepPaths::resolve(&env, Path::new("/home/ada"));
462        assert_eq!(q.pipe_name(), r"\\.\pipe\shep-srv-shep-23b467803966a71a");
463    }
464
465    /// The truncation, and the trim that has to follow it.
466    ///
467    /// Both cases above use a home far under the 64-byte stem cap, so
468    /// nothing reached the slice or the second `trim_end_matches`. This home
469    /// is built so the cut lands exactly on a separator: without that second
470    /// trim the stem would end in a dash and the name would carry a double
471    /// one before its digest.
472    #[test]
473    fn a_stem_longer_than_the_cap_is_cut_and_retrimmed() {
474        let long = format!("/{}/bbbb", "a".repeat(63));
475        let env = |key: &str| (key == "SHEP_HOME").then(|| long.clone());
476        let name = ShepPaths::resolve(&env, Path::new("/home/ada")).pipe_name();
477
478        let stem = name
479            .strip_prefix(r"\\.\pipe\shep-")
480            .expect("the fixed prefix")
481            .rsplit_once('-')
482            .expect("a digest after the stem")
483            .0;
484        assert_eq!(
485            stem,
486            "a".repeat(63),
487            "cut at the cap, then the dash removed"
488        );
489        assert!(
490            !name.contains("--"),
491            "a dash left by the cut would double against the digest's own: {name}"
492        );
493    }
494
495    /// Two homes that differ only past the cut still get their own pipe.
496    ///
497    /// The stem cannot tell them apart by construction, so this is the
498    /// digest's job alone, and the digest is taken over the whole home
499    /// rather than the truncated stem.
500    #[test]
501    fn two_homes_differing_only_past_the_cap_stay_distinct() {
502        let base = "a".repeat(70);
503        let one = format!("/{base}/one");
504        let two = format!("/{base}/two");
505        let env_one = |key: &str| (key == "SHEP_HOME").then(|| one.clone());
506        let env_two = |key: &str| (key == "SHEP_HOME").then(|| two.clone());
507        let a = ShepPaths::resolve(&env_one, Path::new("/home/ada")).pipe_name();
508        let b = ShepPaths::resolve(&env_two, Path::new("/home/ada")).pipe_name();
509        assert_ne!(a, b, "two homes, two pipes, whatever the cut discarded");
510    }
511
512    /// The sanitizer is not injective: `\`, `:` and `-` all become `-`. A
513    /// collision would not error; it would refuse the second daemon as
514    /// already running.
515    #[test]
516    fn two_homes_that_sanitize_alike_get_distinct_pipe_names() {
517        let nested = |key: &str| (key == "SHEP_HOME").then(|| r"C:\a\b".to_string());
518        let dashed = |key: &str| (key == "SHEP_HOME").then(|| r"C:\a-b".to_string());
519        let n = ShepPaths::resolve(&nested, Path::new("/home/ada"));
520        let d = ShepPaths::resolve(&dashed, Path::new("/home/ada"));
521        assert!(
522            n.pipe_name().starts_with(r"\\.\pipe\shep-C--a-b-")
523                && d.pipe_name().starts_with(r"\\.\pipe\shep-C--a-b-"),
524            "the readable stem is what collides, and it stays readable: {} vs {}",
525            n.pipe_name(),
526            d.pipe_name()
527        );
528        assert_ne!(
529            n.pipe_name(),
530            d.pipe_name(),
531            "only the digest keeps two homes that sanitize alike off one pipe"
532        );
533    }
534
535    /// A fake environment, so nothing here touches the process's own.
536    fn os_env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> + use<> {
537        let owned: Vec<(String, OsString)> = pairs
538            .iter()
539            .map(|(k, v)| ((*k).to_string(), OsString::from(*v)))
540            .collect();
541        move |key: &str| owned.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
542    }
543
544    #[test]
545    fn home_is_read_first_on_every_platform() {
546        assert_eq!(
547            user_home(&os_env(&[
548                ("HOME", "/home/ada"),
549                ("USERPROFILE", r"C:\Users\Ada")
550            ])),
551            Some(PathBuf::from("/home/ada"))
552        );
553    }
554
555    #[test]
556    fn nothing_named_resolves_nothing() {
557        assert_eq!(user_home(&os_env(&[])), None);
558    }
559
560    /// An exported-but-empty `HOME` would otherwise resolve `.shep` relative
561    /// to the working directory, which is a different flock per `cd`.
562    #[test]
563    fn an_empty_value_counts_as_unset() {
564        assert_eq!(user_home(&os_env(&[("HOME", "")])), None);
565    }
566
567    /// The bug this fallback exists for: a stock Windows session exports
568    /// `USERPROFILE` and no `HOME` at all, so `shep` refused every command
569    /// with "none of --home, %SHEP_HOME%, or %USERPROFILE% resolves a root
570    /// directory" until 0.7.1.
571    #[cfg(windows)]
572    #[test]
573    fn windows_falls_back_to_userprofile_then_to_the_homedrive_pair() {
574        assert_eq!(
575            user_home(&os_env(&[("USERPROFILE", r"C:\Users\Ada")])),
576            Some(PathBuf::from(r"C:\Users\Ada"))
577        );
578        assert_eq!(
579            user_home(&os_env(&[("HOMEDRIVE", "C:"), ("HOMEPATH", r"\Users\Ada")])),
580            Some(PathBuf::from(r"C:\Users\Ada")),
581            "the two halves concatenate into one path, they do not nest"
582        );
583        assert_eq!(
584            user_home(&os_env(&[("HOMEDRIVE", "C:")])),
585            None,
586            "half the pair names no directory"
587        );
588    }
589
590    /// Unix reads `HOME` alone: a Windows-only fallback firing there would
591    /// resolve a home on a host that deliberately unset one.
592    #[cfg(not(windows))]
593    #[test]
594    fn unix_ignores_the_windows_variables() {
595        assert_eq!(
596            user_home(&os_env(&[
597                ("USERPROFILE", r"C:\Users\Ada"),
598                ("HOMEDRIVE", "C:"),
599                ("HOMEPATH", r"\Users\Ada"),
600            ])),
601            None
602        );
603    }
604}