/ src / util.rs
util.rs
  1  use std::{
  2      env::{self, current_exe},
  3      fs::{self, File},
  4      io::{BufReader, Read as _},
  5      path::{Path, PathBuf},
  6      process::{Command, exit},
  7  };
  8  
  9  use anyhow::{Context as _, Result, anyhow};
 10  
 11  use crate::{SILENT, config::CONFIG};
 12  
 13  /// The absolute path to the users home directory.
 14  pub fn home() -> Result<String> {
 15      env::var("HOME").context("Failed to get HOME env variable")
 16  }
 17  
 18  pub fn get_hostname() -> Result<String> {
 19      Ok(fs::read_to_string("/etc/hostname")
 20          .context("Failed to read /etc/hostname")?
 21          .trim()
 22          .into())
 23  }
 24  
 25  /// Inform the user of the `failed_action` and rerun with root privileges
 26  #[expect(clippy::expect_used)] // We dont return anyways, so we might as well panic
 27  pub fn rerun_with_root(failed_action: &str) -> ! {
 28      if !SILENT.get().expect("Failed to get SILENT") {
 29          println!("{failed_action} requires root privileges",);
 30      }
 31      rerun_with_root_args(&[]);
 32  }
 33  
 34  /// Rerun with root privileges, and add the provided args to the command
 35  #[expect(clippy::expect_used)] // We dont return anyways, so we might as well panic
 36  pub fn rerun_with_root_args(args: &[&str]) -> ! {
 37      // Collect args
 38      let mut args: Vec<_> = env::args()
 39          .chain(args.iter().map(|&str| str.to_owned()))
 40          .collect();
 41  
 42      // Overwrite the exe path with the absolute path if possible
 43      if let Some(absolute_path) = current_exe()
 44          .ok()
 45          .and_then(|path| path.to_str().map(ToOwned::to_owned))
 46      {
 47          args[0] = absolute_path;
 48      }
 49  
 50      let home = home().expect("Failed to get users home di");
 51  
 52      let status = Command::new("/usr/bin/sudo")
 53          // Preserve $HOME
 54          .arg(format!("HOME={home}"))
 55          .args(args)
 56          .spawn()
 57          .expect("Failed to spawn child process")
 58          .wait()
 59          .expect("Failed to wait on child process");
 60  
 61      if status.success() {
 62          exit(0);
 63      } else {
 64          exit(status.code().unwrap_or(1));
 65      }
 66  }
 67  
 68  /// Converts the path relative to files/ to the location on the actual system. (by trimming the subdir of files/ away)
 69  pub fn system_path(path: &Path) -> Result<PathBuf> {
 70      let str = path
 71          .as_os_str()
 72          .to_str()
 73          .context("Failed to convert path to string")?;
 74  
 75      // Replace {home} with the users home dir
 76      let resolved_home = str.replace("{home}", &home()?[1..]);
 77  
 78      Ok(if path.is_relative() {
 79          let index = str
 80              .find('/')
 81              .with_context(|| format!("Failed finding '/' in path '{}'", path.display()))?;
 82  
 83          // Only keep the path from the first /
 84          let absolute = &resolved_home[index..];
 85  
 86          absolute.into()
 87      } else {
 88          // The default subdir was elided, so the path is already the correct one
 89          resolved_home.into()
 90      })
 91  }
 92  
 93  /// Converts the path that should be symlinked to the path in the files/ directory
 94  #[expect(clippy::literal_string_with_formatting_args)]
 95  pub fn config_path(mut cli_path: &Path) -> Result<PathBuf> {
 96      assert!(
 97          !Path::new(&CONFIG.default_subdir).is_absolute(),
 98          "Default subdir is not allowed to be absolute"
 99      );
100  
101      let mut config_path = PathBuf::from(&CONFIG.files_path);
102  
103      // If the path started with "/", the default subdir was elided
104      if let Ok(relative_path) = cli_path.strip_prefix("/") {
105          // So we add it
106          config_path.push(&CONFIG.default_subdir);
107  
108          // And replace the absolute path with the relative one to avoid overwriting the entire config_path
109          cli_path = relative_path;
110      }
111      // If the default subdir wasn't elided, replace "{hostname}" with the actual hostname
112      else if let Ok(stripped_path) = cli_path.strip_prefix("{hostname}") {
113          let hostname = get_hostname();
114          config_path.push(hostname?.trim());
115  
116          cli_path = stripped_path;
117      }
118  
119      // Replace "{home}" with the users home dir
120      if let Ok(stripped_path) = cli_path.strip_prefix("{home}") {
121          let home = home()?;
122          config_path.push(&home[1..]); // skip the leading '/' to avoid overwriting the entire config_path
123  
124          cli_path = stripped_path;
125      }
126  
127      config_path.push(cli_path);
128  
129      Ok(config_path)
130  }
131  
132  /// Checks if the config & system paths are already equal
133  /// Does *not* currently support directories
134  #[expect(clippy::filetype_is_file)]
135  pub fn paths_equal(config_path: &Path, system_path: &Path) -> Result<()> {
136      let fmt_diff = |difference: &str| {
137          Err(anyhow!(
138              "Path {} already exists and differs in {difference} to {}",
139              system_path.display(),
140              config_path.display()
141          ))
142      };
143  
144      // Get metadatas
145      let system_metadata = fs::symlink_metadata(system_path).with_context(|| {
146          format!(
147              "Failed to get metadata for system path {}",
148              system_path.display()
149          )
150      })?;
151      let config_metadata = fs::symlink_metadata(config_path).with_context(|| {
152          format!(
153              "Failed to get metadata for config path {}",
154              config_path.display()
155          )
156      })?;
157  
158      if system_metadata.file_type() != config_metadata.file_type() {
159          fmt_diff("file type")
160      } else if system_metadata.len() != config_metadata.len() {
161          fmt_diff("length")
162      } else if system_metadata.permissions() != config_metadata.permissions() {
163          fmt_diff("permissions")
164          // If they are symlinks
165      } else if system_metadata.file_type().is_symlink()
166      // And their destinations dont match
167          && fs::read_link(system_path).with_context(|| format!("reading symlink destination for path {}", system_path.display()))?
168          != fs::read_link(config_path).with_context(|| format!("reading symlink destination for path {}", config_path.display()))?
169      {
170          fmt_diff("symlink destination")
171      } else if system_metadata.file_type().is_file() {
172          let system_file = File::open(system_path).context("opening system file")?;
173          let config_file = File::open(config_path).context("opening config file")?;
174  
175          let mut system_reader = BufReader::new(system_file);
176          let mut config_reader = BufReader::new(config_file);
177  
178          let mut system_buf = [0; 4096];
179          let mut config_buf = [0; 4096];
180  
181          loop {
182              let system_read = system_reader
183                  .read(&mut system_buf)
184                  .context("reading system file")?;
185  
186              let config_read = config_reader
187                  .read(&mut config_buf)
188                  .context("reading config file")?;
189  
190              if system_read != config_read {
191                  fmt_diff("content length")?;
192              } else if system_read == 0 {
193                  return Ok(()); // EOF & identical
194              } else if system_buf[..system_read] != config_buf[..config_read] {
195                  fmt_diff("file contents")?;
196              }
197          }
198      } else {
199          Ok(())
200      }
201  }