diff --git a/src/uu/env/locales/en-US.ftl b/src/uu/env/locales/en-US.ftl index 60f9261816..884fc9f7a4 100644 --- a/src/uu/env/locales/en-US.ftl +++ b/src/uu/env/locales/en-US.ftl @@ -14,6 +14,7 @@ env-help-ignore-signal = set handling of SIG signal(s) to do nothing env-help-default-signal = reset handling of SIG signal(s) to the default action env-help-block-signal = block delivery of SIG signal(s) while running COMMAND env-help-list-signal-handling = list signal handling changes requested by preceding options +env-help-env0-from = read NUL-delimited environment entries from FILE # Error messages env-error-missing-closing-quote = no terminating quote in -S string at position { $position } for quote '{ $quote }' @@ -36,6 +37,8 @@ env-error-must-specify-command-with-chdir = must specify command with --chdir (- env-error-cannot-change-directory = cannot change directory to { $directory }: { $error } env-error-argv0-not-supported = --argv0 is currently not supported on this platform env-error-failed-set-signal-action = failed to set signal action for signal { $signal }: { $error } +env-error-file-must-end-nul = { $file }: file must end with a NUL byte +env-error-cannot-read-file = cannot read { $file }: { $error } # Warning messages env-warning-no-name-specified = no name specified for value { $value } diff --git a/src/uu/env/locales/fr-FR.ftl b/src/uu/env/locales/fr-FR.ftl index 997456baca..dcac9c6ac8 100644 --- a/src/uu/env/locales/fr-FR.ftl +++ b/src/uu/env/locales/fr-FR.ftl @@ -14,6 +14,7 @@ env-help-ignore-signal = définir la gestion du/des signal/signaux SIG pour ne r env-help-default-signal = réinitialiser la gestion du/des signal/signaux SIG à l'action par défaut env-help-block-signal = bloquer la livraison du/des signal/signaux SIG pendant l'exécution de COMMAND env-help-list-signal-handling = lister les traitements de signaux modifiés par les options précédentes +env-help-env0-from = lire les entrées d'environnement délimitées par NUL depuis FICHIER # Messages d'erreur env-error-missing-closing-quote = aucune guillemet de fermeture dans la chaîne -S à la position { $position } pour la guillemet '{ $quote }' @@ -36,6 +37,8 @@ env-error-must-specify-command-with-chdir = doit spécifier une commande avec -- env-error-cannot-change-directory = impossible de changer de répertoire vers { $directory } : { $error } env-error-argv0-not-supported = --argv0 n'est actuellement pas pris en charge sur cette plateforme env-error-failed-set-signal-action = échec de la définition de l'action du signal pour le signal { $signal } : { $error } +env-error-file-must-end-nul = { $file } : le fichier doit se terminer par un octet NUL +env-error-cannot-read-file = impossible de lire { $file } : { $error } # Messages d'avertissement env-warning-no-name-specified = aucun nom spécifié pour la valeur { $value } diff --git a/src/uu/env/src/env.rs b/src/uu/env/src/env.rs index 02505edbed..56566e96ec 100644 --- a/src/uu/env/src/env.rs +++ b/src/uu/env/src/env.rs @@ -29,12 +29,13 @@ use std::borrow::Cow; use std::collections::BTreeMap; #[cfg(unix)] use std::collections::BTreeSet; +use std::collections::HashMap; use std::env; #[cfg(unix)] use std::ffi::CString; use std::ffi::{OsStr, OsString}; -#[cfg(not(unix))] use std::io; +use std::io::Read as _; use std::io::Write as _; use std::io::stderr; #[cfg(all(unix, not(target_os = "fuchsia")))] @@ -99,12 +100,14 @@ mod options { pub const DEFAULT_SIGNAL: &str = "default-signal"; pub const BLOCK_SIGNAL: &str = "block-signal"; pub const LIST_SIGNAL_HANDLING: &str = "list-signal-handling"; + pub const ENV0_FROM: &str = "env0-from"; } struct Options<'a> { ignore_env: bool, line_ending: LineEnding, running_directory: Option<&'a OsStr>, + env0_from: Option<&'a OsStr>, unsets: Vec<&'a OsStr>, sets: Vec<(Cow<'a, OsStr>, Cow<'a, OsStr>)>, program: Vec<&'a OsStr>, @@ -311,6 +314,205 @@ fn signal_is_valid(sig: usize) -> bool { true } +fn validate_unset_arg(name: &OsStr) -> UResult<()> { + let native_name = NativeStr::new(name); + if name.is_empty() + || native_name.contains('\0').unwrap_or(false) + || native_name.contains('=').unwrap_or(false) + { + return Err(USimpleError::new( + 125, + translate!("env-error-cannot-unset-invalid", "name" => name.quote()), + )); + } + Ok(()) +} + +fn entry_key(entry: &[u8]) -> Option<&[u8]> { + entry + .iter() + .position(|&b| b == b'=') + .map(|pos| &entry[..pos]) +} + +/// Ordered environment entries indexed by key for O(1) lookups. +/// +/// The index maps each key to the position of its FIRST entry. +/// Later duplicates are intentionally unindexed to preserve GNU-compatible semantics. +#[derive(Default)] +struct EnvEntries { + entries: Vec>, + index: HashMap, usize>, +} + +impl EnvEntries { + /// Appends an entry. Only the first occurrence of a key is indexed. + fn push(&mut self, entry: Vec) { + if let Some(key) = entry_key(&entry) { + self.index.entry(key.to_vec()).or_insert(self.entries.len()); + } + self.entries.push(entry); + } + + /// Replaces the first entry matching key if present, otherwise appends it. + fn upsert(&mut self, entry: Vec) { + if let Some(key) = entry_key(&entry) + && let Some(&idx) = self.index.get(key) + { + self.entries[idx] = entry; + return; + } + self.push(entry); + } + + /// Removes all entries matching key and rebuilds the index. + fn remove_key(&mut self, key: &[u8]) { + self.entries.retain(|e| entry_key(e) != Some(key)); + self.index.clear(); + for (i, entry) in self.entries.iter().enumerate() { + if let Some(k) = entry_key(entry) { + self.index.entry(k.to_vec()).or_insert(i); + } + } + } +} + +impl std::ops::Deref for EnvEntries { + type Target = [Vec]; + + fn deref(&self) -> &Self::Target { + &self.entries + } +} + +impl<'a> IntoIterator for &'a EnvEntries { + type Item = &'a Vec; + type IntoIter = std::slice::Iter<'a, Vec>; + + fn into_iter(self) -> Self::IntoIter { + self.entries.iter() + } +} + +fn read_env0_file(file: &OsStr) -> UResult> { + let res = if file == "-" { + let mut buf = Vec::new(); + io::stdin().read_to_end(&mut buf).map(|_| buf) + } else { + std::fs::read(file) + }; + let bytes = res.map_err(|e| { + USimpleError::new( + 125, + translate!( + "env-error-cannot-read-file", + "file" => file.quote(), + "error" => strip_errno(&e) + ), + ) + })?; + + if bytes.last().is_some_and(|&byte| byte != 0) { + return Err(USimpleError::new( + 125, + translate!( + "env-error-file-must-end-nul", + "file" => file.maybe_quote() + ), + )); + } + + Ok(bytes) +} + +// https://doc.rust-lang.org/src/std/sys/env/unix.rs.html +#[cfg(all(unix, target_vendor = "apple"))] +#[allow(clippy::missing_safety_doc)] +unsafe fn environ() -> *mut *mut *mut libc::c_char { + unsafe { libc::_NSGetEnviron() } +} + +#[cfg(all(unix, target_os = "freebsd"))] +#[allow(clippy::missing_safety_doc)] +unsafe fn environ() -> *mut *mut *mut libc::c_char { + use std::sync::LazyLock; + + struct Environ(*mut *mut *mut libc::c_char); + unsafe impl Send for Environ {} + unsafe impl Sync for Environ {} + + static ENVIRON: LazyLock = LazyLock::new(|| { + Environ(unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"environ".as_ptr()).cast() }) + }); + ENVIRON.0 +} + +#[cfg(all(unix, not(any(target_os = "freebsd", target_vendor = "apple"))))] +#[allow(clippy::missing_safety_doc)] +unsafe fn environ() -> *mut *mut *mut libc::c_char { + unsafe extern "C" { + static mut environ: *mut *mut libc::c_char; + } + &raw mut environ +} + +fn load_env0_from(opts: &Options, env0_file: &OsStr) -> UResult { + let mut entries = EnvEntries::default(); + if !opts.ignore_env { + #[cfg(unix)] + { + let mut ptr = unsafe { *environ() }; + + unsafe { + while !ptr.is_null() && !(*ptr).is_null() { + let cstr = std::ffi::CStr::from_ptr(*ptr); + entries.push(cstr.to_bytes().to_vec()); + ptr = ptr.add(1); + } + } + } + #[cfg(not(unix))] + { + for (k, v) in env::vars_os() { + let mut b = k.as_encoded_bytes().to_vec(); + b.push(b'='); + b.extend_from_slice(v.as_encoded_bytes()); + entries.push(b); + } + } + } + + let raw_data = read_env0_file(env0_file)?; + for chunk in raw_data.split_inclusive(|&b| b == 0) { + let entry = chunk[..chunk.len() - 1].to_vec(); + if opts.ignore_env { + entries.push(entry); + } else { + entries.upsert(entry); + } + } + + for name in &opts.unsets { + validate_unset_arg(name)?; + entries.remove_key(name.as_encoded_bytes()); + } + + for (name, val) in &opts.sets { + if name.is_empty() { + show_warning!( + "{}", + translate!("env-warning-no-name-specified", "value" => val.quote()) + ); + continue; + } + let mut new_entry = name.as_encoded_bytes().to_vec(); + new_entry.push(b'='); + new_entry.extend_from_slice(val.as_encoded_bytes()); + entries.upsert(new_entry); + } + + Ok(entries) +} pub fn uu_app() -> Command { Command::new("env") .version(uucore::crate_version!()) @@ -344,6 +546,16 @@ pub fn uu_app() -> Command { .help(translate!("env-help-null")) .action(ArgAction::SetTrue), ) + .arg( + Arg::new(options::ENV0_FROM) + .overrides_with(options::ENV0_FROM) + .long(options::ENV0_FROM) + .value_name("FILE") + .value_hint(clap::ValueHint::FilePath) + .value_parser(ValueParser::os_string()) + .action(ArgAction::Set) + .help(translate!("env-help-env0-from")), + ) .arg( Arg::new(options::UNSET) .short('u') @@ -575,8 +787,12 @@ impl EnvAppData { let mut all_args: Vec = Vec::new(); let mut process_flags = true; let mut expecting_arg = false; - // Leave out split-string since it's a special case below - let flags_with_args = [options::ARGV0, options::CHDIR, options::UNSET]; + let flags_with_args = [ + options::ARGV0, + options::CHDIR, + options::ENV0_FROM, + options::UNSET, + ]; let short_flags_with_args = ['a', 'C', 'u']; let mut consumed_split_payload_arg: Option = None; for (n, arg) in original_args.iter().enumerate() { @@ -782,14 +998,17 @@ impl EnvAppData { &signal_apply_all, )?; - // NOTE: we manually set and unset the env vars below rather than using Command::env() to more - // easily handle the case where no command is given - - apply_removal_of_all_env_vars(&opts); - - apply_unset_env_vars(&opts)?; + #[allow(unused_mut)] + let mut custom_env = opts + .env0_from + .map(|env0_file| load_env0_from(&opts, env0_file)) + .transpose()?; - apply_specified_env_vars(&opts); + if custom_env.is_none() { + apply_removal_of_all_env_vars(&opts); + apply_unset_env_vars(&opts)?; + apply_specified_env_vars(&opts); + } #[cfg(all(unix, not(target_os = "fuchsia")))] { @@ -817,12 +1036,37 @@ impl EnvAppData { } } + #[cfg(all(unix, not(target_os = "fuchsia")))] + if let Some(ref mut entries) = custom_env + && (opts + .default_signal + .signals + .contains(&(libc::SIGPIPE as usize)) + || opts.default_signal.apply_all) + { + let sigpipe_entry = b"RUST_SIGPIPE=default".to_vec(); + entries.upsert(sigpipe_entry); + } + apply_change_directory(&opts)?; if opts.program.is_empty() { - // no program provided, so just dump all env vars to stdout - print_all_env_vars(opts.line_ending)?; + if let Some(ref entries) = custom_env { + let stdout = io::stdout().lock(); + let mut writer = io::BufWriter::new(stdout); + for entry in entries { + writer.write_all(entry)?; + match opts.line_ending { + LineEnding::Nul => writer.write_all(b"\0")?, + LineEnding::Newline => writer.write_all(b"\n")?, + } + } + writer.flush()?; + } else { + // no program provided, so just dump all env vars to stdout + print_all_env_vars(opts.line_ending)?; + } } else { - return self.run_program(&opts, self.do_debug_printing); + return self.run_program(&opts, self.do_debug_printing, custom_env.as_deref()); } Ok(()) @@ -841,6 +1085,7 @@ impl EnvAppData { &mut self, opts: &Options<'_>, do_debug_printing: bool, + custom_env: Option<&[Vec]>, ) -> Result<(), Box> { let prog = Cow::from(opts.program[0]); @@ -903,11 +1148,38 @@ impl EnvAppData { argv.push(arg_cstring); } + let env_cstrings = custom_env.map(|entries| { + entries + .iter() + .filter_map(|e| CString::new(e.clone()).ok()) + .collect::>() + }); + let mut envp_ptrs = env_cstrings.as_ref().map(|cstrings| { + let mut ptrs: Vec<*mut libc::c_char> = + cstrings.iter().map(|cs| cs.as_ptr().cast_mut()).collect(); + ptrs.push(std::ptr::null_mut()); + ptrs + }); + + let orig = envp_ptrs.as_mut().map(|ptrs| unsafe { + let p = *environ(); + *environ() = ptrs.as_mut_ptr(); + p + }); + // Execute the program using execvp. this replaces the current // process. The execvp function takes care of appending a NULL // argument to the argument list so that we don't have to. // unwrap_err since execvp should never return on success - match execvp(&prog_cstring, &argv).unwrap_err() { + let exec_error = execvp(&prog_cstring, &argv).unwrap_err(); + + if let Some(orig) = orig { + unsafe { + *environ() = orig; + } + } + + match exec_error { nix::errno::Errno::ENOENT => Err(self.make_error_no_such_file_or_dir(&prog)), e => { uucore::show_error!("{}: {}", prog.quote(), strip_errno(&e.into())); @@ -921,6 +1193,18 @@ impl EnvAppData { // Fallback to Command::status for non-Unix systems let mut cmd = std::process::Command::new(&*prog); cmd.args(args); + if let Some(entries) = custom_env { + cmd.env_clear(); + for entry in entries { + if let Some(key) = entry_key(entry) { + let val = &entry[key.len() + 1..]; + if let (Ok(k), Ok(v)) = (std::str::from_utf8(key), std::str::from_utf8(val)) + { + cmd.env(k, v); + } + } + } + } match cmd.status() { Ok(exit) if !exit.success() => Err(exit.code().unwrap_or(1).into()), @@ -963,6 +1247,9 @@ fn make_options<'a>( let running_directory = matches .get_one::("chdir") .map(OsString::as_os_str); + let env0_from = matches + .get_one::(options::ENV0_FROM) + .map(OsString::as_os_str); let unsets = match matches.get_many::("unset") { Some(v) => v.map(OsString::as_os_str).collect(), None => Vec::new(), @@ -984,6 +1271,7 @@ fn make_options<'a>( ignore_env, line_ending, running_directory, + env0_from, unsets, sets: vec![], program: vec![], @@ -1024,16 +1312,7 @@ fn make_options<'a>( fn apply_unset_env_vars(opts: &Options<'_>) -> Result<(), Box> { for name in &opts.unsets { - let native_name = NativeStr::new(name); - if name.is_empty() - || native_name.contains('\0').unwrap() - || native_name.contains('=').unwrap() - { - return Err(USimpleError::new( - 125, - translate!("env-error-cannot-unset-invalid", "name" => name.quote()), - )); - } + validate_unset_arg(name)?; unsafe { env::remove_var(name); } diff --git a/tests/by-util/test_env.rs b/tests/by-util/test_env.rs index 7d159c55cd..9cde939e37 100644 --- a/tests/by-util/test_env.rs +++ b/tests/by-util/test_env.rs @@ -2185,3 +2185,98 @@ env: no terminating quote in -S string at position 18 for quote ''' .stderr_is("env: no terminating quote in -S string at position 18 for quote '''\n"); } } + +#[test] +fn test_env0_from_basic() { + new_ucmd!() + .args(&["-i", "--env0-from=-"]) + .pipe_in("A=1\0B=2\0") + .succeeds() + .stdout_is("A=1\nB=2\n"); +} + +#[test] +fn test_env0_from_preserve_duplicates() { + new_ucmd!() + .args(&["-i", "--env0-from=-"]) + .pipe_in("DUP=1\0DUP=2\0") + .succeeds() + .stdout_is("DUP=1\nDUP=2\n"); +} + +#[test] +fn test_env0_from_nonstandard_entries() { + new_ucmd!() + .args(&["-i", "-0", "--env0-from=-"]) + .pipe_in("NOEQUAL\0=STARTEQUAL\0\0A=B\0") + .succeeds() + .stdout_is("NOEQUAL\0=STARTEQUAL\0\0A=B\0"); +} + +#[test] +fn test_env0_from_unset_and_override() { + new_ucmd!() + .args(&["-i", "--env0-from=-", "-u", "DUP"]) + .pipe_in("DUP=1\0DUP=2\0OTHER=3\0") + .succeeds() + .stdout_is("OTHER=3\n"); + + new_ucmd!() + .args(&["-i", "--env0-from=-", "DUP=4"]) + .pipe_in("DUP=1\0DUP=2\0") + .succeeds() + .stdout_is("DUP=4\nDUP=2\n"); +} + +#[test] +fn test_env0_from_empty_file() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + new_ucmd!() + .arg("-i") + .arg(format!("--env0-from={}", tmp.path().display())) + .succeeds() + .no_stdout(); +} + +#[test] +fn test_env0_from_missing_trailing_nul() { + new_ucmd!() + .args(&["-i", "--env0-from=-"]) + .pipe_in("A=1\0B=2") + .fails_with_code(125) + .stderr_is("env: -: file must end with a NUL byte\n"); +} + +#[test] +fn test_env0_from_nonexistent_file() { + new_ucmd!() + .arg("--env0-from=/nonexistent_file_12345") + .fails_with_code(125) + .stderr_contains("cannot read '/nonexistent_file_12345'"); +} + +#[test] +fn test_env0_from_with_command() { + new_ucmd!() + .args(&["-i", "--env0-from=-", "echo", "hello"]) + .pipe_in("FOO=BAR\0") + .succeeds() + .stdout_is("hello\n"); +} + +#[test] +fn test_env0_from_multiple_last_wins() { + let mut tmp1 = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp1, b"A=1\0").unwrap(); + let mut tmp2 = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp2, b"B=2\0").unwrap(); + + new_ucmd!() + .args(&[ + "-i", + &format!("--env0-from={}", tmp1.path().display()), + &format!("--env0-from={}", tmp2.path().display()), + ]) + .succeeds() + .stdout_is("B=2\n"); +}