diff --git a/rust/hg-core/src/utils/path.rs b/rust/hg-core/src/utils/path.rs new file mode 100644 --- /dev/null +++ b/rust/hg-core/src/utils/path.rs @@ -0,0 +1,305 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This software may be used and distributed according to the terms of the + * GNU General Public License version 2. + */ + +//! Path-related utilities. + +use std::env; +#[cfg(not(unix))] +use std::fs::rename; +use std::fs::{self, remove_file as fs_remove_file}; +use std::io::{self, ErrorKind}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::Result; +#[cfg(not(unix))] +use tempfile::Builder; + +/// Normalize a canonicalized Path for display. +/// +/// This removes the UNC prefix `\\?\` on Windows. +pub fn normalize_for_display(path: &str) -> &str { + if cfg!(windows) && path.starts_with(r"\\?\") { + &path[4..] + } else { + path + } +} + +/// Similar to [`normalize_for_display`]. But work on bytes. +pub fn normalize_for_display_bytes(path: &[u8]) -> &[u8] { + if cfg!(windows) && path.starts_with(br"\\?\") { + &path[4..] + } else { + path + } +} + +/// Return the absolute and normalized path without accessing the filesystem. +/// +/// Unlike [`fs::canonicalize`], do not follow symlinks. +/// +/// This function does not access the filesystem. Therefore it can behave +/// differently from the kernel or other library functions in corner cases. +/// For example: +/// +/// - On some systems with symlink support, `foo/bar/..` and `foo` can be +/// different as seen by the kernel, if `foo/bar` is a symlink. This +/// function always returns `foo` in this case. +/// - On Windows, the official normalization rules are much more complicated. +/// See https://github.com/rust-lang/rust/pull/47363#issuecomment-357069527. +/// For example, this function cannot translate "drive relative" path like +/// "X:foo" to an absolute path. +/// +/// Return an error if `std::env::current_dir()` fails or if this function +/// fails to produce an absolute path. +pub fn absolute(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + let path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + + if !path.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("cannot get absoltue path from {:?}", path), + )); + } + + let mut result = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(_) | Component::RootDir | Component::Prefix(_) => { + result.push(component); + } + Component::ParentDir => { + result.pop(); + } + Component::CurDir => (), + } + } + Ok(result) +} + +/// Remove the file pointed by `path`. +#[cfg(unix)] +pub fn remove_file>(path: P) -> Result<()> { + fs_remove_file(path)?; + Ok(()) +} + +/// Remove the file pointed by `path`. +/// +/// On Windows, removing a file can fail for various reasons, including if the file is memory +/// mapped. This can happen when the repository is accessed concurrently while a background task is +/// trying to remove a packfile. To solve this, we can rename the file before trying to remove it. +/// If the remove operation fails, a future repack will clean it up. +#[cfg(not(unix))] +pub fn remove_file>(path: P) -> Result<()> { + let path = path.as_ref(); + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map_or(".to-delete".to_owned(), |ext| ".".to_owned() + ext + "-tmp"); + + let dest_path = Builder::new() + .prefix("") + .suffix(&extension) + .rand_bytes(8) + .tempfile_in(path.parent().unwrap())? + .into_temp_path(); + + rename(path, &dest_path)?; + + // Ignore errors when removing the file, it will be cleaned up at a later time. + let _ = fs_remove_file(dest_path); + Ok(()) +} + +/// Create the directory and ignore failures when a directory of the same name already exists. +pub fn create_dir(path: impl AsRef) -> io::Result<()> { + match fs::create_dir(path.as_ref()) { + Ok(()) => Ok(()), + Err(e) => { + if e.kind() == ErrorKind::AlreadyExists && path.as_ref().is_dir() { + Ok(()) + } else { + Err(e) + } + } + } +} + +/// Expand the user's home directory and any environment variables references in +/// the given path. +/// +/// This function is designed to emulate the behavior of Mercurial's `util.expandpath` +/// function, which in turn uses Python's `os.path.expand{user,vars}` functions. This +/// results in behavior that is notably different from the default expansion behavior +/// of the `shellexpand` crate. In particular: +/// +/// - If a reference to an environment variable is missing or invalid, the reference +/// is left unchanged in the resulting path rather than emitting an error. +/// +/// - Home directory expansion explicitly happens after environment variable +/// expansion, meaning that if an environment variable is expanded into a +/// string starting with a tilde (`~`), the tilde will be expanded into the +/// user's home directory. +/// +pub fn expand_path(path: impl AsRef) -> PathBuf { + expand_path_impl(path.as_ref(), |k| env::var(k).ok(), dirs::home_dir) +} + +/// Same as `expand_path` but explicitly takes closures for environment variable +/// and home directory lookup for the sake of testability. +fn expand_path_impl(path: &str, getenv: E, homedir: H) -> PathBuf +where + E: FnMut(&str) -> Option, + H: FnOnce() -> Option, +{ + // The shellexpand crate does not expand Windows environment variables + // like `%PROGRAMDATA%`. We'd like to expand them too. So let's do some + // pre-processing. + // + // XXX: Doing this preprocessing has the unfortunate side-effect that + // if an environment variable fails to expand on Windows, the resulting + // string will contain a UNIX-style environment variable reference. + // + // e.g., "/foo/%MISSING%/bar" will expand to "/foo/${MISSING}/bar" + // + // The current approach is good enough for now, but likely needs to + // be improved later for correctness. + let path = { + let mut new_path = String::new(); + let mut is_starting = true; + for ch in path.chars() { + if ch == '%' { + if is_starting { + new_path.push_str("${"); + } else { + new_path.push('}'); + } + is_starting = !is_starting; + } else if cfg!(windows) && ch == '/' { + // Only on Windows, change "/" to "\" automatically. + // This makes sure "%include /foo" works as expected. + new_path.push('\\') + } else { + new_path.push(ch); + } + } + new_path + }; + + let path = shellexpand::env_with_context_no_errors(&path, getenv); + shellexpand::tilde_with_context(&path, homedir) + .as_ref() + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::fs::File; + + use tempfile::TempDir; + + #[cfg(windows)] + mod windows { + use super::*; + + #[test] + fn test_absolute_fullpath() { + assert_eq!(absolute("C:/foo").unwrap(), Path::new("C:\\foo")); + assert_eq!( + absolute("x:\\a/b\\./.\\c").unwrap(), + Path::new("x:\\a\\b\\c") + ); + assert_eq!( + absolute("y:/a/b\\../..\\c\\../d\\./.").unwrap(), + Path::new("y:\\d") + ); + assert_eq!( + absolute("z:/a/b\\../..\\../..\\..").unwrap(), + Path::new("z:\\") + ); + } + } + + #[cfg(unix)] + mod unix { + use super::*; + + #[test] + fn test_absolute_fullpath() { + assert_eq!(absolute("/a/./b\\c/../d/.").unwrap(), Path::new("/a/d")); + assert_eq!(absolute("/a/../../../../b").unwrap(), Path::new("/b")); + assert_eq!(absolute("/../../..").unwrap(), Path::new("/")); + assert_eq!(absolute("/../../../").unwrap(), Path::new("/")); + assert_eq!( + absolute("//foo///bar//baz").unwrap(), + Path::new("/foo/bar/baz") + ); + assert_eq!(absolute("//").unwrap(), Path::new("/")); + } + } + + #[test] + fn test_create_dir_non_exist() -> Result<()> { + let tempdir = TempDir::new()?; + let mut path = tempdir.path().to_path_buf(); + path.push("dir"); + create_dir(&path)?; + assert!(path.is_dir()); + Ok(()) + } + + #[test] + fn test_create_dir_exist() -> Result<()> { + let tempdir = TempDir::new()?; + let mut path = tempdir.path().to_path_buf(); + path.push("dir"); + create_dir(&path)?; + assert!(&path.is_dir()); + create_dir(&path)?; + assert!(&path.is_dir()); + Ok(()) + } + + #[test] + fn test_create_dir_file_exist() -> Result<()> { + let tempdir = TempDir::new()?; + let mut path = tempdir.path().to_path_buf(); + path.push("dir"); + File::create(&path)?; + let err = create_dir(&path).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::AlreadyExists); + Ok(()) + } + + #[test] + fn test_path_expansion() { + fn getenv(key: &str) -> Option { + match key { + "foo" => Some("~/a".into()), + "bar" => Some("b".into()), + _ => None, + } + } + + fn homedir() -> Option { + Some(PathBuf::from("/home/user")) + } + + let path = "$foo/${bar}/$baz"; + let expected = PathBuf::from("/home/user/a/b/$baz"); + + assert_eq!(expand_path_impl(&path, getenv, homedir), expected); + } +}