diff --git a/brush-core/src/builtins/cd.rs b/brush-core/src/builtins/cd.rs index 319b4c68..17239f5a 100644 --- a/brush-core/src/builtins/cd.rs +++ b/brush-core/src/builtins/cd.rs @@ -9,11 +9,11 @@ use crate::{builtins, commands}; #[derive(Parser)] pub(crate) struct CdCommand { /// Force following symlinks. - #[arg(short = 'L')] + #[arg(short = 'L', overrides_with = "use_physical_dir")] force_follow_symlinks: bool, /// Use physical dir structure without following symlinks. - #[arg(short = 'P')] + #[arg(short = 'P', overrides_with = "force_follow_symlinks")] use_physical_dir: bool, /// Exit with non zero exit status if current working directory resolution fails. @@ -35,16 +35,12 @@ impl builtins::Command for CdCommand { &self, context: commands::ExecutionContext<'_>, ) -> Result { - // TODO: implement options - if self.force_follow_symlinks - || self.use_physical_dir - || self.exit_on_failed_cwd_resolution - || self.file_with_xattr_as_dir - { + if self.exit_on_failed_cwd_resolution || self.file_with_xattr_as_dir { return crate::error::unimp("options to cd"); } let mut should_print = false; + let target_dir = if let Some(target_dir) = &self.target_dir { // `cd -', equivalent to `cd $OLDPWD' if target_dir.as_os_str() == "-" { @@ -69,16 +65,28 @@ impl builtins::Command for CdCommand { } }; - if let Err(e) = context.shell.set_working_dir(&target_dir) { + // TODO: CDPATH, LCD_PRINTPATH, LCD_DOSPELL and LCD_DOVARS + + let result = if self.use_physical_dir { + context.shell.set_current_working_dir(&target_dir) + // the logical dir by default + } else { + context + .shell + .set_current_working_dir_from_logical(&target_dir) + }; + + if let Err(e) = result { writeln!(context.stderr(), "cd: {e}")?; return Ok(builtins::ExitCode::Custom(1)); } // Bash compatibility // https://www.gnu.org/software/bash/manual/bash.html#index-cd - // If a non-empty directory name from CDPATH is used, or if '-' is the first argument, and - // the directory change is successful, the absolute pathname of the new working - // directory is written to the standard output. + // If a non-empty directory name from CDPATH is used, or if '-' is the first + // argument, and the directory change is successful, the absolute + // pathname of the new working directory is written to the standard + // output. if should_print { writeln!(context.stdout(), "{}", target_dir.display())?; } diff --git a/brush-core/src/builtins/dirs.rs b/brush-core/src/builtins/dirs.rs index 9024f72c..5f539f2a 100644 --- a/brush-core/src/builtins/dirs.rs +++ b/brush-core/src/builtins/dirs.rs @@ -33,9 +33,16 @@ impl builtins::Command for DirsCommand { if self.clear { context.shell.directory_stack.clear(); } else { - let dirs = vec![&context.shell.working_dir] + let dirs = vec![context.shell.get_current_working_dir()] .into_iter() - .chain(context.shell.directory_stack.iter().rev()) + .chain( + context + .shell + .directory_stack + .iter() + .rev() + .map(|p| p.as_path()), + ) .collect::>(); let one_per_line = self.print_one_per_line || self.print_one_per_line_with_index; diff --git a/brush-core/src/builtins/popd.rs b/brush-core/src/builtins/popd.rs index d525febe..eec46abf 100644 --- a/brush-core/src/builtins/popd.rs +++ b/brush-core/src/builtins/popd.rs @@ -20,7 +20,7 @@ impl builtins::Command for PopdCommand { ) -> Result { if let Some(popped) = context.shell.directory_stack.pop() { if !self.no_directory_change { - context.shell.set_working_dir(&popped)?; + context.shell.set_current_working_dir(&popped)?; } // Display dirs. diff --git a/brush-core/src/builtins/pushd.rs b/brush-core/src/builtins/pushd.rs index 595dedd6..e72338ad 100644 --- a/brush-core/src/builtins/pushd.rs +++ b/brush-core/src/builtins/pushd.rs @@ -26,10 +26,10 @@ impl builtins::Command for PushdCommand { .directory_stack .push(std::path::PathBuf::from(&self.dir)); } else { - let prev_working_dir = context.shell.working_dir.clone(); + let prev_working_dir = context.shell.get_current_working_dir().to_path_buf(); let dir = std::path::Path::new(&self.dir); - context.shell.set_working_dir(dir)?; + context.shell.set_current_working_dir(dir)?; context.shell.directory_stack.push(prev_working_dir); } diff --git a/brush-core/src/builtins/pwd.rs b/brush-core/src/builtins/pwd.rs index 8ad06db9..3363804e 100644 --- a/brush-core/src/builtins/pwd.rs +++ b/brush-core/src/builtins/pwd.rs @@ -6,11 +6,11 @@ use std::io::Write; #[derive(Parser)] pub(crate) struct PwdCommand { /// Print the physical directory without any symlinks. - #[arg(short = 'P')] + #[arg(short = 'P', overrides_with = "allow_symlinks")] physical: bool, /// Print $PWD if it names the current working directory. - #[arg(short = 'L')] + #[arg(short = 'L', overrides_with = "physical")] allow_symlinks: bool, } @@ -19,19 +19,21 @@ impl builtins::Command for PwdCommand { &self, context: commands::ExecutionContext<'_>, ) -> Result { - // - // TODO: implement flags - // TODO: look for 'physical' option in execution context - // + // POSIX: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/pwd.html - if self.physical || self.allow_symlinks { - writeln!(context.stderr(), "UNIMPLEMENTED: pwd with -P or -L")?; - return Ok(builtins::ExitCode::Unimplemented); - } + // TODO: look for 'physical' option in execution context options (set -P) - let cwd = context.shell.working_dir.to_string_lossy().into_owned(); + // if POSIXLY_CORRECT is set, we want to a logical resolution. + // This produces a different output when doing mkdir -p a/b && ln -s a/b c && cd c && pwd + // We should get c in this case instead of a/b at the end of the path + let cwd = if self.physical && context.shell.env.get_str("POSIXLY_CORRECT").is_none() { + context.shell.get_current_working_dir() + // -L logical by default or when POSIXLY_CORRECT is set + } else { + context.shell.get_current_logical_working_dir() + }; - writeln!(context.stdout(), "{cwd}")?; + writeln!(context.stdout(), "{}", cwd.display())?; Ok(builtins::ExitCode::Success) } diff --git a/brush-core/src/commands.rs b/brush-core/src/commands.rs index fbdb1c6b..f9a66990 100644 --- a/brush-core/src/commands.rs +++ b/brush-core/src/commands.rs @@ -216,7 +216,7 @@ pub(crate) fn compose_std_command>( } // Use the shell's current working dir. - cmd.current_dir(shell.working_dir.as_path()); + cmd.current_dir(shell.working_dir.physical()); // Start with a clear environment. cmd.env_clear(); diff --git a/brush-core/src/completion.rs b/brush-core/src/completion.rs index 716ae3d1..7e00ba36 100644 --- a/brush-core/src/completion.rs +++ b/brush-core/src/completion.rs @@ -260,7 +260,7 @@ impl Spec { .set_extended_globbing(shell.options.extended_globbing); let expansions = pattern.expand( - shell.working_dir.as_path(), + shell.working_dir.physical(), Some(&patterns::Pattern::accept_all_expand_filter), )?; @@ -922,7 +922,7 @@ fn get_file_completions(shell: &Shell, context: &Context, must_be_dir: bool) -> patterns::Pattern::from(glob).set_extended_globbing(shell.options.extended_globbing); pattern - .expand(shell.working_dir.as_path(), Some(&path_filter)) + .expand(shell.working_dir.physical(), Some(&path_filter)) .unwrap_or_default() .into_iter() .collect() diff --git a/brush-core/src/expansion.rs b/brush-core/src/expansion.rs index 16291b01..8d4b75a4 100644 --- a/brush-core/src/expansion.rs +++ b/brush-core/src/expansion.rs @@ -504,7 +504,7 @@ impl<'a> WordExpander<'a> { let expansions = pattern .expand( - self.shell.working_dir.as_path(), + self.shell.working_dir.physical(), Some(&patterns::Pattern::accept_all_expand_filter), ) .unwrap_or_default(); diff --git a/brush-core/src/interp.rs b/brush-core/src/interp.rs index bff91793..05c78f6b 100644 --- a/brush-core/src/interp.rs +++ b/brush-core/src/interp.rs @@ -6,7 +6,7 @@ use std::io::Write; use std::os::fd::{AsFd, AsRawFd}; #[cfg(unix)] use std::os::unix::process::ExitStatusExt; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use crate::arithmetic::ExpandAndEvaluate; @@ -1257,15 +1257,15 @@ pub(crate) async fn setup_redirect<'a>( return Err(error::Error::InvalidRedirection); } - let expanded_file_path: PathBuf = - shell.get_absolute_path(Path::new(expanded_fields.remove(0).as_str())); + let expanded_file_path = PathBuf::from(expanded_fields.remove(0)); + let expanded_file_path = shell.get_absolute_path(&expanded_file_path); let opened_file = std::fs::File::options() .create(true) .write(true) .truncate(!*append) .append(*append) - .open(expanded_file_path.as_path()) + .open(&expanded_file_path) .map_err(|err| { error::Error::RedirectionFailure( expanded_file_path.to_string_lossy().to_string(), @@ -1331,16 +1331,15 @@ pub(crate) async fn setup_redirect<'a>( return Err(error::Error::InvalidRedirection); } - let expanded_file_path: PathBuf = - shell.get_absolute_path(Path::new(expanded_fields.remove(0).as_str())); + let expanded_file_path = PathBuf::from(expanded_fields.remove(0)); + let expanded_file_path = shell.get_absolute_path(&expanded_file_path); - let opened_file = - options.open(expanded_file_path.as_path()).map_err(|err| { - error::Error::RedirectionFailure( - expanded_file_path.to_string_lossy().to_string(), - err, - ) - })?; + let opened_file = options.open(&expanded_file_path).map_err(|err| { + error::Error::RedirectionFailure( + expanded_file_path.to_string_lossy().to_string(), + err, + ) + })?; target_file = OpenFile::File(opened_file); } ast::IoFileRedirectTarget::Fd(fd) => { diff --git a/brush-core/src/patterns.rs b/brush-core/src/patterns.rs index 728621c7..e0544b7b 100644 --- a/brush-core/src/patterns.rs +++ b/brush-core/src/patterns.rs @@ -285,7 +285,8 @@ impl Pattern { } if self.multiline { - // Set option for multiline matching + set option for allowing '.' pattern to match newline. + // Set option for multiline matching + set option for allowing '.' pattern to match + // newline. regex_str.push_str("(?ms)"); } diff --git a/brush-core/src/prompt.rs b/brush-core/src/prompt.rs index 682d7207..625b88c6 100644 --- a/brush-core/src/prompt.rs +++ b/brush-core/src/prompt.rs @@ -108,7 +108,7 @@ pub(crate) fn format_prompt_piece( } fn format_current_working_directory(shell: &Shell, tilde_replaced: bool, basename: bool) -> String { - let mut working_dir_str = shell.working_dir.to_string_lossy().to_string(); + let mut working_dir_str = shell.working_dir.physical().to_string_lossy().to_string(); if tilde_replaced { working_dir_str = shell.tilde_shorten(working_dir_str); diff --git a/brush-core/src/shell.rs b/brush-core/src/shell.rs index 559c4b99..0241f4e4 100644 --- a/brush-core/src/shell.rs +++ b/brush-core/src/shell.rs @@ -9,7 +9,7 @@ use crate::arithmetic::Evaluatable; use crate::env::{EnvironmentLookup, EnvironmentScope, ShellEnvironment}; use crate::interp::{self, Execute, ExecutionParameters, ExecutionResult}; use crate::options::RuntimeOptions; -use crate::sys::fs::PathExt; +use crate::sys::fs::{AbsolutePath, PathExt}; use crate::variables::{self, ShellValue, ShellVariable}; use crate::{ builtins, commands, completion, env, error, expansion, functions, jobs, keywords, openfiles, @@ -26,7 +26,7 @@ pub struct Shell { /// Manages files opened and accessible via redirection operators. pub open_files: openfiles::OpenFiles, /// The current working directory. - pub working_dir: PathBuf, + pub working_dir: Cwd, /// The shell environment, containing shell variables. pub env: ShellEnvironment, /// Shell function definitions. @@ -174,11 +174,43 @@ impl Shell { /// * `options` - The options to use when creating the shell. pub async fn new(options: &CreateOptions) -> Result { // Instantiate the shell with some defaults. + + let mut env = Self::initialize_vars(options)?; + + // shortcut for resetting the pwd to a good state + let resetpwd = |env: &mut ShellEnvironment, path: String| { + env.update_or_add( + "PWD", + variables::ShellValueLiteral::Scalar(path), + |var| { + var.export(); + Ok(()) + }, + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + ) + }; + + let physical_working_dir = std::env::current_dir()?; + let pwd = env.get_str("PWD"); + let logical_working_dir = { + // logical path is mainteined by the parent shell in the PWD environment variable + if let Some(pwd) = &pwd { + let home = Self::get_home_dir_with_env(&env).unwrap_or_default(); + let pwd = crate::sys::fs::expand_tilde_with_home(&**pwd, home); + AbsolutePath::new(&physical_working_dir, pwd) + } else { + resetpwd(&mut env, physical_working_dir.to_string_lossy().to_string())?; + AbsolutePath::try_from_absolute(&physical_working_dir).unwrap() + } + }; + let cwd = Cwd::from_logical(logical_working_dir)?; + let mut shell = Shell { traps: traps::TrapHandlerConfig::default(), open_files: openfiles::OpenFiles::default(), - working_dir: std::env::current_dir()?, - env: Self::initialize_vars(options)?, + working_dir: cwd, + env, funcs: functions::FunctionEnv::default(), options: RuntimeOptions::defaults_from(options), jobs: jobs::JobManager::new(), @@ -301,7 +333,7 @@ impl Shell { // self.source_if_exists(Path::new("/etc/profile"), ¶ms) .await?; - if let Some(home_path) = self.get_home_dir() { + if let Some(home_path) = self.get_home_dir().map(|h| h.to_path_buf()) { if options.sh_mode { self.source_if_exists(home_path.join(".profile").as_path(), ¶ms) .await?; @@ -335,7 +367,7 @@ impl Shell { // self.source_if_exists(Path::new("/etc/bash.bashrc"), ¶ms) .await?; - if let Some(home_path) = self.get_home_dir() { + if let Some(home_path) = self.get_home_dir().map(|h| h.to_path_buf()) { self.source_if_exists(home_path.join(".bashrc").as_path(), ¶ms) .await?; self.source_if_exists(home_path.join(".brushrc").as_path(), ¶ms) @@ -895,7 +927,7 @@ impl Shell { .set_extended_globbing(self.options.extended_globbing); // TODO: Pass through quoting. - if let Ok(entries) = pattern.expand(&self.working_dir, Some(&is_executable)) { + if let Ok(entries) = pattern.expand(self.working_dir.physical(), Some(&is_executable)) { for entry in entries { executables.push(PathBuf::from(entry)); } @@ -946,19 +978,6 @@ impl Shell { } } - /// Gets the absolute form of the given path. - /// - /// # Arguments - /// - /// * `path` - The path to get the absolute form of. - pub fn get_absolute_path(&self, path: &Path) -> PathBuf { - if path.is_absolute() { - path.to_owned() - } else { - self.working_dir.join(path) - } - } - /// Opens the given file. /// /// # Arguments @@ -970,7 +989,9 @@ impl Shell { path: &Path, params: &ExecutionParameters, ) -> Result { - let path_to_open = self.get_absolute_path(path); + let home = self.get_home_dir().unwrap_or_default(); + let path = crate::sys::fs::expand_tilde_with_home(path, home); + let path_to_open = crate::sys::fs::make_absolute(self.working_dir.physical(), path); // See if this is a reference to a file descriptor, in which case the actual // /dev/fd* file path for this process may not match with what's in the execution @@ -990,84 +1011,6 @@ impl Shell { Ok(std::fs::File::open(path_to_open)?.into()) } - /// Sets the shell's current working directory to the given path. - /// - /// # Arguments - /// - /// * `target_dir` - The path to set as the working directory. - pub fn set_working_dir(&mut self, target_dir: &Path) -> Result<(), error::Error> { - let abs_path = self.get_absolute_path(target_dir); - - match std::fs::metadata(&abs_path) { - Ok(m) => { - if !m.is_dir() { - return Err(error::Error::NotADirectory(abs_path)); - } - } - Err(e) => { - return Err(e.into()); - } - } - - // TODO: Don't canonicalize, just normalize. - let cleaned_path = abs_path.canonicalize()?; - - let pwd = cleaned_path.to_string_lossy().to_string(); - - self.env.update_or_add( - "PWD", - variables::ShellValueLiteral::Scalar(pwd), - |var| { - var.export(); - Ok(()) - }, - EnvironmentLookup::Anywhere, - EnvironmentScope::Global, - )?; - let oldpwd = std::mem::replace(&mut self.working_dir, cleaned_path); - - self.env.update_or_add( - "OLDPWD", - variables::ShellValueLiteral::Scalar(oldpwd.to_string_lossy().to_string()), - |var| { - var.export(); - Ok(()) - }, - EnvironmentLookup::Anywhere, - EnvironmentScope::Global, - )?; - - Ok(()) - } - - /// Tilde-shortens the given string, replacing the user's home directory with a tilde. - /// - /// # Arguments - /// - /// * `s` - The string to shorten. - pub(crate) fn tilde_shorten(&self, s: String) -> String { - if let Some(home_dir) = self.get_home_dir() { - if let Some(stripped) = s.strip_prefix(home_dir.to_string_lossy().as_ref()) { - return format!("~{stripped}"); - } - } - s - } - - /// Returns the shell's current home directory, if available. - pub(crate) fn get_home_dir(&self) -> Option { - Self::get_home_dir_with_env(&self.env) - } - - fn get_home_dir_with_env(env: &ShellEnvironment) -> Option { - if let Some((_, home)) = env.get("HOME") { - Some(PathBuf::from(home.value().to_cow_string().to_string())) - } else { - // HOME isn't set, so let's sort it out ourselves. - users::get_current_user_home_dir() - } - } - /// Returns a value that can be used to write to the shell's currently configured /// standard output stream using `write!` at al. pub fn stdout(&self) -> openfiles::OpenFile { @@ -1161,3 +1104,175 @@ fn parse_string_impl( tracing::debug!(target: trace_categories::PARSE, "Parsing string as program..."); parser.parse(true) } + +// The part of the shell that manages paths and the cwd +impl Shell { + /// Tilde-shortens the given string, replacing the user's home directory with a tilde. + /// + /// # Arguments + /// + /// * `s` - The string to shorten. + pub(crate) fn tilde_shorten(&self, s: String) -> String { + if let Some(home_dir) = self.get_home_dir() { + if let Some(stripped) = s.strip_prefix(home_dir.to_string_lossy().as_ref()) { + return format!("~{stripped}"); + } + } + s + } + + /// Returns the shell's current home directory, if available. + pub(crate) fn get_home_dir(&self) -> Option> { + Self::get_home_dir_with_env(&self.env) + } + + fn get_home_dir_with_env(env: &ShellEnvironment) -> Option> { + if let Some(home) = env.get_str("HOME") { + match home { + Cow::Borrowed(home) => Some(Cow::from(Path::new(home))), + Cow::Owned(home) => Some(Cow::from(PathBuf::from(home))), + } + } else { + // HOME isn't set, so let's sort it out ourselves. + users::get_current_user_home_dir().map(Cow::from) + } + } + + /// Gets the absolute form of the given path. + /// + /// Note that this functions uses the physical cwd + /// + /// # Arguments + /// + /// * `path` - The path to get the absolute form of. + /// + /// returns the reference of the `path` if it is already absolute, or the new absolute path + pub fn get_absolute_path<'a>(&'a self, path: &'a Path) -> AbsolutePath<'a> { + let home = self.get_home_dir().unwrap_or_default(); + let path = crate::sys::fs::expand_tilde_with_home(path, home); + AbsolutePath::new(self.working_dir.physical(), path) + } + + /// Gets the shell physical current working directory + pub fn get_current_working_dir(&self) -> &Path { + self.working_dir.physical() + } + /// Gets the shell logical current working directory + pub fn get_current_logical_working_dir(&self) -> &Path { + self.working_dir.logical() + } + + /// Sets the shell working directory from a physical path. + /// + /// If the current working directory needs to be set from the logical path, use + /// [`Shell::set_current_working_dir_from_logical`] instead. + /// + /// Note that the logical cwd will be equal to the physical one. + /// + /// # Arguments + /// + /// * `physical` - The path to set cwd from. + pub fn set_current_working_dir(&mut self, physical: &Path) -> Result<(), error::Error> { + // NOTE: need to be careful with the system api such as [`std::fs::canonicalize`] because it + // implicitly uses the process cwd when expands ./, ../, ~/. So we need to make out + // path absolute asap + let physical = self.get_absolute_path(physical); + let cwd = Cwd::from_physical(physical)?; + self.set_current_working_dir_impl(cwd) + } + + /// Sets the shell working directory from a logical path. + /// + /// If the current working directory needs to be set from the physical path, use + /// [`Shell::set_current_working_dir`] instead. + /// + /// # Arguments + /// + /// * `logical` - The path to set cwd from. + pub fn set_current_working_dir_from_logical( + &mut self, + logical: &Path, + ) -> Result<(), error::Error> { + let home = self.get_home_dir().unwrap_or_default(); + let logical = crate::sys::fs::expand_tilde_with_home(logical, home); + let logical = AbsolutePath::new(self.working_dir.logical(), logical); + let cwd = Cwd::from_logical(logical)?; + self.set_current_working_dir_impl(cwd) + } + + fn set_current_working_dir_impl(&mut self, cwd: Cwd) -> Result<(), error::Error> { + // first, update the pwd because we can get an error + self.env.update_or_add( + "PWD", + variables::ShellValueLiteral::Scalar(cwd.logical().to_string_lossy().to_string()), + |var| { + var.export(); + Ok(()) + }, + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + + let old_cwd = std::mem::replace(&mut self.working_dir, cwd); + + // not the most important thing, so assign it at the end + self.env.update_or_add( + "OLDPWD", + variables::ShellValueLiteral::Scalar(old_cwd.logical().to_string_lossy().to_string()), + |var| { + var.export(); + Ok(()) + }, + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + + Ok(()) + } +} + +/// The shell's current working directory. +/// +/// Consists of the physical path where symlinks are resolved and the logical path, which +/// contains symlinks and is maintained by the shell. +#[derive(Debug, Clone)] +pub struct Cwd { + physical: PathBuf, + logical: PathBuf, +} + +impl Cwd { + /// Gets the physical cwd + pub fn physical(&self) -> &Path { + &self.physical + } + + /// Gets the logical cwd + pub fn logical(&self) -> &Path { + &self.logical + } + + fn from_physical(physical: AbsolutePath) -> Result { + let physical = std::fs::canonicalize(physical.into_inner())?; + + if !std::fs::metadata(&physical)?.is_dir() { + return Err(error::Error::NotADirectory(physical)); + } + + Ok(Cwd { + logical: physical.clone(), + physical, + }) + } + + fn from_logical(logical: AbsolutePath) -> Result { + let logical = crate::sys::fs::normalize_lexically(logical).to_path_buf(); + let physical = std::fs::canonicalize(&logical)?; + + if !std::fs::metadata(&physical)?.is_dir() { + return Err(error::Error::NotADirectory(logical)); + } + + Ok(Cwd { physical, logical }) + } +} diff --git a/brush-core/src/sys/fs.rs b/brush-core/src/sys/fs.rs index d78fbf17..d070f788 100644 --- a/brush-core/src/sys/fs.rs +++ b/brush-core/src/sys/fs.rs @@ -1,8 +1,12 @@ #[allow(unused_imports)] pub(crate) use super::platform::fs::*; +use std::borrow::Cow; +use std::ffi::OsStr; +use std::ops::Deref; #[cfg(unix)] pub(crate) use std::os::unix::fs::MetadataExt; +use std::path::{Component, Path, PathBuf}; #[cfg(not(unix))] pub(crate) use StubMetadataExt as MetadataExt; @@ -19,3 +23,589 @@ pub(crate) trait PathExt { fn exists_and_is_setuid(&self) -> bool; fn exists_and_is_sticky_bit(&self) -> bool; } + +/// An error returned from [`AbsolutePath::from_absolute`] if the path is not absolute. +#[derive(Debug)] +pub struct IsNotAbsoluteError

(P); + +/// A wrapper around [`std::path::Path`] to indicate that certain +/// functions, such as [`normalize_lexically`], require an absolute path to work correctly. +pub struct AbsolutePath<'a>( + Cow<'a, Path>, /* May hold either `&Path` or `PathBuf` */ +); +impl<'a> AbsolutePath<'a> { + /// Consumes the [`AbsolutePath`], yielding its internal [`Cow<'a, Path>`](Cow) storage. + pub fn into_inner(self) -> Cow<'a, Path> { + self.0 + } + + /// Constructs an absolute path from the given `path` relative to the base `relative_to` + /// + /// Uses [`make_absolute`] to construct the absolute path. + /// See its documentation for more. + /// + /// # Arguments + /// + /// - `relative_to` - A base path (similar to `cwd`) which the `path` is relative to. + /// - `path` - A [`TildaExpandedPath`] to make absolute. + pub fn new(relative_to: R, path: TildaExpandedPath<'a>) -> Self + where + std::path::PathBuf: From, + Cow<'a, Path>: From, + std::path::PathBuf: From<&'a str>, + R: AsRef, + { + AbsolutePath(make_absolute(relative_to, path)) + } + + /// Constructs [`AbsolutePath`] from any path that is already absolute; otherwise, + /// returns an error [`IsNotAbsoluteError`]. + /// + /// # Arguments + /// + /// - `path` - An absolute absolute. + pub fn try_from_absolute

(path: P) -> Result> + where + P: AsRef, + Cow<'a, Path>: From

, + { + if path.as_ref().is_absolute() { + Ok(AbsolutePath(Cow::from(path))) + } else { + Err(IsNotAbsoluteError(path)) + } + } +} + +impl AsRef for AbsolutePath<'_> { + fn as_ref(&self) -> &Path { + &self.0 + } +} +impl Deref for AbsolutePath<'_> { + type Target = Path; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// A Type to explicitly indicate that the path doesn't contait a telde '~'. +/// +/// This `struct` is created by the [`expand_tilde_with_home`] function. +pub struct TildaExpandedPath<'a>(Cow<'a, Path>); +impl<'a> TildaExpandedPath<'a> { + pub fn into_inner(self) -> Cow<'a, Path> { + self.0 + } +} + +/// Makes `path` absolute using `relative_to` as the base. +/// +/// Does nothing if the path is already absolute. +/// +/// Note that the function requires the [`TildaExpandedPath`] as the `path` created by +/// [`expand_tilde_with_home`] because otherwise the result could end up as "/some/path/~" or +/// "/some/path/~user". +/// +/// # Arguments +/// +/// - `relative_to` - A base path (similar to `cwd`) which the `path` is relative to. +/// - `path` - A [`TildaExpandedPath`] to make absolute. +pub fn make_absolute<'a, R>(relative_to: R, path: TildaExpandedPath<'a>) -> Cow<'a, Path> +where + // If `R` is a `&Path` convert it to `Path::to_path_buf()` only if nessesarry, if it is a `PathBuf`, + // return the argument unchanged without additional allocations. + std::path::PathBuf: From, + std::path::PathBuf: From<&'a str>, + R: AsRef, + Cow<'a, Path>: From, +{ + let path = path.into_inner(); + + // Windows verbatim paths should not be modified. + if path.as_ref().is_absolute() || is_verbatim(&path) { + path + } else { + if path.as_ref().as_os_str().as_encoded_bytes() == b"." { + // Joining a Path with '.' appends a '.' at the end, + // so we don't do anything, which should result in an equal + // path on all supported systems. + return relative_to.into(); + } + relative_join(relative_to, &path).into() + } +} + +/// Creates a new path where: +/// - Multiple `/`'s are collapsed to a single `/`. +/// - Leading `./`'s and trailing `/.`'s are removed. +/// - `../`'s are handled by removing portions of the path. +/// +/// Note that unlike [`std::fs::canonicalize`], this function: +/// - doesn't use syscalls (such as `readlink`). +/// - doesn't convert to absolute form. +/// - doesn't resolve symlinks. +/// - Does not check's if path actually exists. +/// +/// Because of this, the function strictly requires an absolute path. +/// +/// # Arguments +/// +/// - `path` - An [`AbsolutePath`]. +pub fn normalize_lexically(path: AbsolutePath<'_>) -> Cow<'_, Path> { + let path = path.into_inner(); + + if is_normalized(&path) { + return path; + } + // NOTE: This is mostly taken from std::path:absolute, except we don't use + // [`std::env::current_dir()`] here + + #[cfg_attr(not(unix), allow(unused_mut))] + let mut components = path.components(); + let path_os = path.as_os_str().as_encoded_bytes(); + + let mut normalized = { + // https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_13 + // Posix: "If a pathname begins with two successive characters, the + // first component following the leading characters may be + // interpreted in an implementation-defined manner, although more than + // two leading characters shall be treated as a single + // character." + #[cfg(unix)] + { + if path_os.starts_with(b"//") && !path_os.starts_with(b"///") { + components.next(); + PathBuf::from("//") + } else { + PathBuf::new() + } + } + #[cfg(not(unix))] + { + PathBuf::new() + } + }; + + for component in components { + match component { + Component::Prefix(..) => { + // The Windows prefix here such as C:/, unc or verbatim. + // On Unix, C:/ is not allowed because such a path is considered non-absolute and + // will be rejected by [`AbsolutePath`] API." + #[cfg(windows)] + { + normalized.push(component.as_os_str()) + } + #[cfg(not(windows))] + { + unreachable!() + } + } + Component::RootDir => { + normalized.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(c) => { + normalized.push(c); + } + } + } + + // An empty result is really the root path because we've started with an absolute path + if normalized.as_os_str().is_empty() { + normalized.push( + // at least one component, since we've already checked the path for emptiness + path.components().next().unwrap(), + ); + } + + // "Interfaces using pathname resolution may specify additional constraints + // when a pathname that does not name an existing directory contains at + // least one non- character and contains one or more trailing + // characters". + // A trailing is also meaningful if "a symbolic link is + // encountered during pathname resolution". + if path_os.ends_with(b"/") || path_os.ends_with(std::path::MAIN_SEPARATOR_STR.as_bytes()) { + normalized.push(""); + } + Cow::from(normalized) +} + +// A verbatim `\\?\` path means that no normalization must be performed. +// Paths prefixed with `\\?\` are passed (almost) directly to the Windows kernel without any +// transformations or substitutions. +fn is_verbatim(path: &Path) -> bool { + match path.components().next() { + Some(Component::Prefix(prefix)) => prefix.kind().is_verbatim(), + _ => false, + } +} + +// Checks if the path is already normalized and whether [`normalize_lexically`] can be skipped. +// A path considered normalized if it is: +// - empty +// - a verbatim path on Windows +// - ends with `/` or additionally `\` on Windows +// - doesn't contain `.` and `..` +// - doesn't have multiple path separators (e.g., a//b) +fn is_normalized(path: &Path) -> bool { + let path_os = path.as_os_str().as_encoded_bytes(); + + if path.as_os_str().is_empty() { + return true; + } + + #[cfg(windows)] + if is_verbatim(path) { + return true; + } + + // require ending `/` + if !(path_os.ends_with(b"/") + // check '\' + || (cfg!(windows) && path_os.ends_with(std::path::MAIN_SEPARATOR_STR.as_bytes()))) + { + return false; + } + + // does not have any of `.`, `..` + if path + .components() + .any(|c| matches!(c, Component::CurDir | Component::ParentDir)) + { + return false; + } + + // contains any of the doubled slashes, such as a/b//d. + if path.as_os_str().len() > 1 { + // Skip the first `//` in POSIX, but not when the first is `///`. + // skip the first \\ or // in Windows UNC and Device paths + !path_os[1..] + .windows(2) + .any(|window| window == b"//" || (cfg!(windows) && window == br"\\")) + } else { + true + } +} + +/// Performs tilde expansion. +/// Returns a [`TildaExpandedPath`] type that indicates the path is expanded and ready for further +/// processing. +/// +/// # Arguments +/// +/// - `path` - A path to expand `~`. +/// - `home` - A path that `~` should be expanded to. +pub fn expand_tilde_with_home<'a, P, H>(path: &'a P, home: H) -> TildaExpandedPath<'a> +where + std::path::PathBuf: From, + H: AsRef + 'a, + P: AsRef + ?Sized, + Cow<'a, Path>: From<&'a Path>, + Cow<'a, Path>: From, +{ + // let path = path.as_ref(); + let mut components = path.as_ref().components(); + let path = match components.next() { + Some(Component::Normal(p)) if p.as_encoded_bytes() == b"~" => components.as_path(), + // is already expanded + _ => return TildaExpandedPath(path.as_ref().into()), + }; + + if home.as_ref().as_os_str().is_empty() || home.as_ref().as_os_str().as_encoded_bytes() == b"/" + { + // Corner case: `home` is a root directory; + // don't prepend extra `/`, just drop the tilde. + return TildaExpandedPath(Cow::from(path)); + } + + // Corner case: `p` is empty; + // Don't append extra '/', just keep `home` as is. + // This happens because PathBuf.push will always + // add a separator if the pushed path is relative, + // even if it's empty + if path.as_os_str().as_encoded_bytes().is_empty() { + return TildaExpandedPath(home.into()); + } + let mut home = PathBuf::from(home); + home.push(path); + TildaExpandedPath(home.into()) +} + +/// A wrapper around [`Path::join`] with additional handling of the Windows's volume relative +/// paths (e.g `C:file` - A relative path from the current directory of the C: drive.) +// https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats +fn relative_join(base: C, path: &Path) -> PathBuf +where + std::path::PathBuf: From, + for<'a> std::path::PathBuf: From<&'a OsStr>, + C: AsRef, +{ + #[cfg(windows)] + if let (Some(Component::Prefix(cwd_prefix)), Some(Component::Prefix(path_prefix))) = + (base.as_ref().components().next(), path.components().next()) + { + let path = path.strip_prefix(path_prefix.as_os_str()).unwrap(); + // C:\cwd + C:data -> C:\cwd\data + if path_prefix == cwd_prefix { + let cwd = PathBuf::from(base); + return cwd.join(path); + } + // C:\cwd + D:data -> D:\data + let mut rtn = PathBuf::from(path_prefix.as_os_str()); + rtn.reserve(std::path::MAIN_SEPARATOR_STR.len() + path.as_os_str().len()); + rtn.push(std::path::MAIN_SEPARATOR_STR); + rtn.push(path); + return rtn; + } + let cwd = PathBuf::from(base); + cwd.join(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_normalized() { + let tests_normalized: &[&[&str]] = &[ + &[ + "/aa/bb/cc/dd/", + #[cfg(unix)] + "//aa/special/posix/case/", + ], + #[cfg(windows)] + &[ + "C:/aa/bb/", + r"C:\aa\bb\", + r"C:\aa/b/", + r"\\?\pictures\..\kittens", + r"\\?\UNC\server\share", + r"\\?\c$", + r"\\.\pictures\kittens\", + r"//.\pictures\kittens\", + r"\\.\UNC\server\share\", + r"\\server\share/", + ], + ]; + + let tests_not_normalized: &[&[&str]] = &[ + &[ + "/aa/bb/cc/dd", + "/aa/bb/../cc/dd", + "///aa/bb/cc/dd", + "///////aa/bb/cc/dd", + "./aa/bb/cc/dd", + "/aa/bb//cc/dd", + "/aa/bb////cc/./dd", + "/aa/bb////cc/dd", + r"\\.\pictures\..\kittens", + r"\\.\UNC\server\share", + r"\\server\share", + ], + #[cfg(windows)] + &["C:/aa/bb", r"C:\\\aa\bb\", r"C:\aa///b/"], + ]; + + for test in tests_normalized.into_iter().map(|s| *s).flatten() { + assert!(is_normalized(&Path::new(test)), "{}", test); + } + for test in tests_not_normalized.into_iter().map(|s| *s).flatten() { + assert!(!is_normalized(&Path::new(test)), "{}", test); + } + } + + #[test] + fn test_make_absolute() { + let home = Path::new(if cfg!(unix) { + "/home" + } else { + r"C:\Users\Home\" + }); + + let cwd = Path::new(if cfg!(unix) { "/cwd" } else { r"C:\cwd" }); + + let tests: &[&[(&str, &str)]] = &[ + #[cfg(unix)] + &[ + ("~/aa/bb/", "/home/aa/bb"), + ("./", "/cwd/"), + (".", "/cwd/"), + #[cfg(unix)] + ("//the/absolute/posix", "//the/absolute/posix"), + ], + #[cfg(windows)] + &[ + ("~/aa/bb/", r"C:\Users\Home\aa/bb"), + ("./", r"C:\cwd\"), + (".", r"C:\cwd\"), + // super dumb relative paths + ("Z:my_folder", r"Z:\my_folder"), + ("Z:", r"Z:\"), + ("C:my_folder", r"C:\cwd\my_folder"), + // verbatim and unc + (r"\\server\share\.\da\..\f\", r"\\server\share\.\da\..\f\"), + (r"\\?\pics\..\of\./kittens", r"\\?\pics\..\of\./kittens"), + (r"\\?\UNC\ser\share\data\..\", r"\\?\UNC\ser\share\data\..\"), + (r"\\?\c:\..\..\..\..\../", r"\\?\c:\..\..\..\..\../"), + (r"\\.\PIPE\name\../surname", r"\\.\PIPE\name\../surname/"), + (r"\\server\share\..\data", r"\\server\share\..\data\"), + ], + ]; + + for test in tests.into_iter().map(|s| *s).flatten() { + assert_eq!( + make_absolute(cwd, expand_tilde_with_home(&Path::new(test.0), home)), + Path::new(test.1) + ); + } + } + + #[test] + #[cfg(unix)] + fn test_normalize_lexically() { + let tests = vec![ + ("/", "/"), + ("//", "//"), + ("///", "/"), + ("/.//", "/"), + ("//..", "/"), + ("/..//", "/"), + ("/..//", "/"), + ("/.//./", "/"), + ("/././/./", "/"), + ("/./././", "/"), + ("/path//to///thing", "/path/to/thing"), + ("/aa/bb/../cc/dd", "/aa/cc/dd"), + ("/../aa/bb/../../cc/dd", "/cc/dd"), + ("/../../../../aa/bb/../../cc/dd", "/cc/dd"), + ("/aa/bb/../../cc/dd/../../../../../../../../../", "/"), + ("/../../../../../../..", "/"), + ("/../../../../../...", "/..."), + ("/test/./path/", "/test/path"), + ("/test/../..", "/"), + ("/./././", "/"), + ("///root/../home", "/home"), + #[cfg(unix)] + ("//root/../home", "//home"), + ]; + + for test in tests { + assert_eq!( + normalize_lexically(AbsolutePath::try_from_absolute(Path::new(test.0)).unwrap()), + Path::new(test.1) + ); + assert_eq!( + normalize_lexically(AbsolutePath::new( + Path::new(test.0), + expand_tilde_with_home(&Path::new(test.0), Path::new("/home")) + )), + Path::new(test.1) + ); + } + + // empty path is a and empty path + assert_eq!( + normalize_lexically(AbsolutePath::new( + Path::new(""), + TildaExpandedPath(Cow::from(Path::new(""))) + )), + Path::new("") + ); + } + + #[test] + #[cfg(windows)] + fn test_normalize_lexically_windows() { + let tests = vec![ + (r"C:\..", r"C:\"), + (r"C:\../..\..\..\..", r"C:\"), + (r"C:\..\test", r"C:\test"), + (r"C:\test\..", r"C:\"), + (r"C:\test\path\..\..\..", r"C:\"), + (r"C:\test\path/..\../another\\path", r"C:\another\path"), + (r"C:\test\path\\my/path", r"C:\test\path\my\path"), + (r"C:/dir\../otherDir/test.json", "C:/otherDir/test.json"), + (r"c:\test\..", r"c:\"), + ("c:/test/..", "c:/"), + (r"\\server\share\.\data\..\file\", r"\\server\share\file\"), + // any of the verbatim paths should stay unchanged + (r"\\?\pics\..\of\./kittens", r"\\?\pics\..\of\./kittens"), + (r"\\?\UNC\ser\share\data\..\", r"\\?\UNC\ser\share\data\..\"), + (r"\\?\c:\..\..\..\..\../", r"\\?\c:\..\..\..\..\../"), + // other windows stuff + (r"\\.\PIPE\name\../surname/", r"\\.\PIPE\surname\"), + (r"\\.\PIPE\remove_all\..\..\..\..\..\..\", r"\\.\PIPE\"), + // server\share is a part of the prefix + (r"\\server\share\..\data\.\", r"\\server\share\data\"), + (r"Z:\", r"Z:\"), + ]; + + for test in tests { + assert_eq!( + normalize_lexically( + AbsolutePath::try_from_absolute(PathBuf::from(test.0)).unwrap() + ), + PathBuf::from(test.1) + ); + } + } + + #[test] + fn test_expand_tilde() { + let home = Path::new(if cfg!(unix) { + "/home" + } else { + r"C:\Users\Home\" + }); + let check_expanded = |s: &str| { + assert!(expand_tilde_with_home(Path::new(s), home) + .into_inner() + .starts_with(home)); + + // Tests the special case in expand_tilde for "/" as home + let home = Path::new("/"); + assert!(!expand_tilde_with_home(Path::new(s), home) + .into_inner() + .starts_with("//")); + }; + + let check_not_expanded = |s: &str| { + let expanded = expand_tilde_with_home(Path::new(s), home).into_inner(); + assert_eq!(expanded, Path::new(s)); + }; + + let tests_expanded = vec!["~", "~/test/", "~//test/"]; + let tests_not_expanded: &[&[&str]] = &[ + &["1~1", "~user/", ""], + // windows special + &[r"\\.\~", r"\\?\~\", r"\\.\UNC\~", r"\\~"], + ]; + + for test in tests_expanded { + check_expanded(test) + } + for test in tests_not_expanded.into_iter().map(|s| *s).flatten() { + check_not_expanded(test) + } + } + + #[test] + #[cfg(windows)] + fn test_windows_weirdo_volume_relative_path() { + let cwd = Path::new(r"C:\cwd"); + + assert_eq!( + relative_join(cwd, Path::new(r"C:data")), + Path::new(r"C:\cwd\data") + ); + assert_eq!( + relative_join(cwd, Path::new(r"D:data")), + Path::new(r"D:\data") + ); + } +} diff --git a/brush-interactive/src/completion.rs b/brush-interactive/src/completion.rs index bec9319e..d34357a5 100644 --- a/brush-interactive/src/completion.rs +++ b/brush-interactive/src/completion.rs @@ -9,7 +9,7 @@ pub(crate) async fn complete_async( line: &str, pos: usize, ) -> brush_core::completion::Completions { - let working_dir = shell.working_dir.clone(); + let working_dir = shell.working_dir.physical().to_path_buf(); // Intentionally ignore any errors that arise. let completion_future = shell.get_completions(line, pos); diff --git a/brush-shell/tests/cases/builtins/cd.yaml b/brush-shell/tests/cases/builtins/cd.yaml index 67b860f7..274a41bd 100644 --- a/brush-shell/tests/cases/builtins/cd.yaml +++ b/brush-shell/tests/cases/builtins/cd.yaml @@ -41,3 +41,78 @@ cases: echo $? echo "pwd: $PWD" + + - name: "cd ~" + ignore_stderr: true + stdin: | + mkdir ./my_home + export HOME="$(realpath ./my_home)" + ( + cd ~ + echo "pwd: $(basename $PWD)" + ) + ( + cd -L ~ + echo "pwd: $(basename $PWD)" + ) + ( + cd -P ~ + echo "pwd: $(basename $PWD)" + ) + + + - name: "cd -LP" + ignore_stderr: true + stdin: | + mkdir -p ./level1/level2/level3 + cd level1 + ln -s ./level2/level3 ./symlink + + # -L by default + ( + cd ./symlink + echo "$(basename $PWD)" + cd .. + echo "$(basename $PWD)" + ) + ( + cd ./symlink + echo "$(basename $PWD)" + cd -L .. + echo "$(basename $PWD)" + ) + ( + cd ./symlink + echo "$(basename $PWD)" + cd -P .. + echo "$(basename $PWD)" + ) + ( + cd -P ./symlink + echo "$(basename $PWD)" + cd .. + echo "$(basename $PWD)" + ) + ( + cd -L ./symlink + echo "$(basename $PWD)" + cd .. + echo "$(basename $PWD)" + ) + # without pwd + ( + cd -L ./symlink + echo "$(basename $PWD)" + export PWD= + cd -L .. + echo "$(basename $PWD)" + ) + ( + cd -L ./symlink + export PWD= + # start a shell without $PWD + ( + cd . + echo "$(basename $PWD)" + ) + ) diff --git a/brush-shell/tests/cases/builtins/pwd.yaml b/brush-shell/tests/cases/builtins/pwd.yaml index c216e0ba..07f8e2ab 100644 --- a/brush-shell/tests/cases/builtins/pwd.yaml +++ b/brush-shell/tests/cases/builtins/pwd.yaml @@ -9,3 +9,39 @@ cases: cd usr pwd echo "Result: $?" + + - name: "pwd -LP" + ignore_stderr: true + stdin: | + mkdir -p ./level1/level2/level3 + cd level1 + ln -s ./level2/level3 ./symlink + + ( + cd -L ./symlink + basename $(pwd) + basename $(pwd -L) + basename $(pwd -P) + ) + ( + cd -L ./symlink + export PWD= + basename $(pwd) + basename $(pwd -L) + basename $(pwd -P) + ) + ( + cd -L ./symlink + export PWD= + # start a shell without $PWD + ( + basename $(pwd) + basename $(pwd -L) + basename $(pwd -P) + ) + ) + + cd ~ + pwd + pwd -L + pwd -P diff --git a/brush-shell/tests/completion_tests.rs b/brush-shell/tests/completion_tests.rs index a9b16a01..ad3e4f58 100644 --- a/brush-shell/tests/completion_tests.rs +++ b/brush-shell/tests/completion_tests.rs @@ -34,7 +34,7 @@ impl TestShellWithBashCompletion { return Err(anyhow::anyhow!("failed to source bash completion script")); } - shell.set_working_dir(temp_dir.path())?; + shell.set_current_working_dir(temp_dir.path())?; Ok(Self { shell, temp_dir }) }