Skip to content

Prevent mutating the global environment pointer in CommandExt::exec and opt to use execve and resolve path manually#157144

Open
asder8215 wants to merge 1 commit into
rust-lang:mainfrom
asder8215:commandext_exec_bug_fix
Open

Prevent mutating the global environment pointer in CommandExt::exec and opt to use execve and resolve path manually#157144
asder8215 wants to merge 1 commit into
rust-lang:mainfrom
asder8215:commandext_exec_bug_fix

Conversation

@asder8215
Copy link
Copy Markdown
Contributor

@asder8215 asder8215 commented May 30, 2026

This PR fixes the bug described in #156951 where CommandExt::exec mutating the global environment pointer could cause correctness issue with concurrent exec calls (also this function holds an environment read lock, so it shouldn't cause any writes to the global environment pointer anyways). We do everything within CommandExt::exec via execve than execvp, which means we have to resolve the path manually.

I took reference to what was done in an archived (Feb 2026) upstream mirror of glibc's execvp and execvpe. I also took reference to #55359 on how they implemented the concerned portion of CommandExt::exec with execve. There's also this OpenBSD libc implementation of execvpe that I saw, but I didn't deeply take a look at this one and saw how it differ.

Other than that, I'm unsure how to handle the error ETIMEOUT/TimedOut as glibc breaks parsing through the PATH environment variable value and returns the error while #55359 continues parsing.

Let me know if you also want me to put some of execve code in a helper function or refactor it in other ways.

…nd opt to use execve and resolve path manually
@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-libs Relevant to the library team, which will review and decide on the PR/issue. labels May 30, 2026
@rustbot
Copy link
Copy Markdown
Collaborator

rustbot commented May 30, 2026

r? @joboet

rustbot has assigned @joboet.
They will have a look at your PR within the next two weeks and either review your PR or reassign to another reviewer.

Use r? to explicitly pick a reviewer

Why was this reviewer chosen?

The reviewer was selected based on:

  • Owners of files modified in this PR: @ChrisDenton, libs
  • @ChrisDenton, libs expanded to 8 candidates
  • Random selection from Mark-Simulacrum, joboet

@rust-log-analyzer
Copy link
Copy Markdown
Collaborator

The job pr-check-1 failed! Check out the build log: (web) (plain enhanced) (plain)

Click to see the possible cause of the failure (guessed by this bot)
[RUSTC-TIMING] object test:false 8.665
error[E0425]: cannot find value `NAME_MAX` in crate `libc`
   --> library/std/src/sys/process/unix/unix.rs:420:50
    |
420 |         else if file_as_bytes.len() - 1 >= libc::NAME_MAX as usize {
    |                                                  ^^^^^^^^ not found in `libc`

For more information about this error, try `rustc --explain E0425`.
[RUSTC-TIMING] std test:false 4.545
error: could not compile `std` (lib) due to 1 previous error

Copy link
Copy Markdown
Member

@joboet joboet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I highly recommend having a look at the POSIX specification – GNU/Linux mostly follows that, but the specification sometimes leaves some things unspecified and we should be careful not to break those.

Also, this is currently unsound, we cannot perform any memory allocation in do_exec, since it's called after fork for the standard spawn path. You probably need to preallocate some memory when creating the Command (or when setting certain parameters).

View changes since this review

return Err(io::Error::from_raw_os_error(libc::ENOENT));
}
// Path searching does not occur when our file starts with a `/`
else if file_as_bytes.starts_with(&[b'/']) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mismatches the POSIX specification, the file is interpreted as path if it contains a slash character in any place.

Also, I'd move this check to before all the path stuff, that's redundant if this path is taken.

let argv = self.get_argv();
let parent_path = crate::env::var("PATH")
.map(|var| CString::new(var))
.unwrap_or(CString::new("/bin:/usr/bin"))?;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like this duplication of the default path. Can you factor this differently?

return Err(io::Error::from_raw_os_error(libc::ENAMETOOLONG));
} else {
let mut got_perm_denied = None;
while paths != &[b'\0'] {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole logic is equivalent to split_paths

None => c"/bin:/usr/bin".to_bytes_with_nul(),
}
}
None => parent_path.as_bytes_with_nul(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend moving the environment variable lookup here so that it's only performed when actually needed.

// Remove "PATH="
Some(p) => &p.to_bytes_with_nul()[5..],
// Falls back to this if PATH environment variable couldn't be found
None => c"/bin:/usr/bin".to_bytes_with_nul(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This default is incorrect. E.g. on GNU/Linux you should use confstr(_CS_PATH) (see sys/pal/unix/conf.rs for a safe wrapper). _CS_PATH is probably the right choice in general, but might not be enough to match execvp (POSIX specifies that the fallback is "implementation defined").

binary_path.extend_from_slice(dir);
binary_path.push(b'/');
binary_path.extend_from_slice(file.to_bytes());
let cstr_path = CString::new(binary_path)?;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This searches for nuls even though none can be present (both dir and file come from a CStr).

let parent_path = crate::env::var("PATH")
.map(|var| CString::new(var))
.unwrap_or(CString::new("/bin:/usr/bin"))?;
let envp = maybe_envp.map(|envp| envp.as_ptr()).unwrap_or(*sys::env::environ());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably a good idea to hold the environment read lock if inheriting the environment from the parent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the environment read lock always held before do_exec is called?

do_exec (as far as I can tell) is called from Command::spawn & Command:exec, which I see that they invoke sys::env::read_lock before calling do_exec

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right, fair enough. That means that the parent PATH lookup cannot use env::var_os since RwLock does not allow recursive read locks (you shouldn't use it anyway as it allocates).

Copy link
Copy Markdown
Contributor Author

@asder8215 asder8215 May 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That means that the parent PATH lookup cannot use env::var_os since RwLock does not allow recursive read locks (you shouldn't use it anyway as it allocates).

That's fine, I think I can use libc::getenv(key.as_ptr()) as *const libc::c_char and CStr::from_ptr to do the PATH lookup (this is what getenv does underneath the hood).

Comment on lines +417 to +421
// Ensure that our given file does not exceed the limit set by NAME_MAX
// Note: `file` is a CStr, so it should be guaranteed to have a nul terminated
// byte (hence no underflow occurs here)
else if file_as_bytes.len() - 1 >= libc::NAME_MAX as usize {
return Err(io::Error::from_raw_os_error(libc::ENAMETOOLONG));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd just leave this check to execve...

&& err == libc::ENOEXEC
{
let mut new_argv = CStringArray::with_capacity(argv.len() + 2);
new_argv.push(CString::new("/bin/sh")?);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path is implementation defined – though I imagine it's correct for nearly all platforms.

@rustbot rustbot added S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. and removed S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. labels May 30, 2026
@rustbot
Copy link
Copy Markdown
Collaborator

rustbot commented May 30, 2026

Reminder, once the PR becomes ready for a review, use @rustbot ready.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. T-libs Relevant to the library team, which will review and decide on the PR/issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants