Skip to main content

cli/
home.rs

1//! Home-directory resolution and path expansion helpers.
2//!
3//! These are crate-wide utilities (re-exported through `crate::config` for
4//! backward compatibility): they resolve the *effective* home directory,
5//! which differs from `$HOME` when running under `sudo`.
6
7use anyhow::Result;
8use directories::UserDirs;
9use std::path::PathBuf;
10
11/// Return the home directory of the original (pre-sudo) user when the process
12/// is running under `sudo`, or `None` if not applicable.
13///
14/// `sudo` sets `SUDO_USER` to the invoking user's login name and resets `HOME`
15/// to root's home, causing the config to be read from the wrong directory.
16/// We resolve the correct home by looking up the user in the passwd database.
17#[cfg(unix)]
18fn sudo_user_home() -> Option<PathBuf> {
19    let sudo_user = std::env::var("SUDO_USER").ok()?;
20    let sudo_user = sudo_user.trim();
21    if sudo_user.is_empty() || sudo_user == "root" {
22        return None;
23    }
24    // /etc/passwd is authoritative for local accounts on both Linux and macOS.
25    let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
26    for line in passwd.lines() {
27        let mut fields = line.splitn(7, ':');
28        let username = fields.next()?;
29        if username != sudo_user {
30            continue;
31        }
32        // passwd field order: name:password:uid:gid:gecos:home:shell
33        let home = fields.nth(4)?; // skip password, uid, gid, gecos (index 1-4)
34        if !home.is_empty() {
35            return Some(PathBuf::from(home));
36        }
37    }
38    None
39}
40
41#[cfg(not(unix))]
42fn sudo_user_home() -> Option<PathBuf> {
43    None
44}
45
46pub(crate) fn effective_home_dir() -> PathBuf {
47    if let Some(home) = sudo_user_home() {
48        return home;
49    }
50    if let Ok(home) = std::env::var("HOME") {
51        let home = home.trim().to_string();
52        if !home.is_empty() {
53            return PathBuf::from(home);
54        }
55    }
56    UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf())
57}
58
59/// Expand a leading `~` using the effective home directory instead of `HOME`.
60/// Needed because `sudo` resets `HOME` to `/root`.
61pub fn tilde_expand(s: &str) -> String {
62    let home = effective_home_dir().to_string_lossy().into_owned();
63    shellexpand::tilde_with_context(s, || Some(home)).into_owned()
64}
65
66/// Like `shellexpand::full` but uses the effective home for both `~` and `$HOME`.
67pub fn full_expand(s: &str) -> Result<String, shellexpand::LookupError<std::env::VarError>> {
68    full_expand_with_home(s, &effective_home_dir())
69}
70
71/// Like `full_expand` but takes an explicit home directory instead of reading the environment.
72/// Use this when a `Config` is available — pass `&config.home_dir` to avoid a data race in tests.
73pub fn full_expand_with_home(
74    s: &str,
75    home: &std::path::Path,
76) -> Result<String, shellexpand::LookupError<std::env::VarError>> {
77    let home = home.to_string_lossy().into_owned();
78    let home2 = home.clone();
79    shellexpand::full_with_context(
80        s,
81        move || Some(home),
82        move |var| {
83            if var == "HOME" {
84                return Ok(Some(home2.clone()));
85            }
86            match std::env::var(var) {
87                Ok(v) => Ok(Some(v)),
88                Err(std::env::VarError::NotPresent) => Ok(None),
89                Err(e) => Err(e),
90            }
91        },
92    )
93    .map(|c| c.into_owned())
94}
95
96fn default_config_dir() -> Result<PathBuf> {
97    Ok(effective_home_dir().join(".shine"))
98}
99
100pub(crate) fn default_config_and_presets_dir() -> Result<(PathBuf, PathBuf)> {
101    let config_dir = default_config_dir()?;
102    Ok((config_dir.clone(), config_dir.join("presets")))
103}