Skip to content

Fix:Introduce an interface to expose the current Command captured env var logic #194

Open
@schneems

Description

@schneems

Proposal

Context

The Command struct allows developers to execute system commands (such as bundle install through a Rust interface). It includes modifying (set) and reading (get): env, current working directory, args, and program name.

When you execute a command, it will resolve both "explicit" environment variables (set via env, envs, env_remove) and implicit "inherited" environment variables (modifiable by env_clear) via this logic https://github.com/rust-lang/rust/blob/3a5c8e91f094bb1cb1346651fe3512f0b603d826/library/std/src/sys_common/process.rs#L36-L51 and then pass the result to the operating system. I will refer to this code as environment variable "capture" logic.

There are two problems with this process, detailed in the next section.

When a Command runs, the environment variables it uses are affected by:

  • Explicit mappings set (via Command::envs and Command::env)
  • Implicit mappings, inherited via the parent process
  • Explicitly denied-listed single inherited mapping (via Command::remove_env)
  • Implicit deny-list all inherited mappings (via Command::env_clear)

To reliably determine the exact environment variables a child process used when it ran, we must have those four pieces of information. There is a public API to determine the explicit mappings Command::get_envs. This API also tells us what values were explicitly removed via Command::remove_env. However, it does not include information about inherited environment variables. From the docs:

When Command::env_clear executes, it sets an internal state of clear=true. With this flag, the child process is told to not inherit any environment variable mapping from the parent.

That means we've got four possible states an environment variable can be in:

  • A) env_clear NOT called, explicit mapping set
  • B) env_clear called, explicit mapping set
  • C) env_clear NOT called, explicit mapping NOT set
  • D) env_clear called, explicit mapping NOT set

Without knowing if env_clear was called, states A & B and B & D are indistinguishable. This lack of information prevents us from determining the exact environment variables a Command used when it ran.

Problem statement(s)

  • Problem 1: Cannot observe effects of Command::env_clear
  • Problem 2: The "capture" logic has no exposed single source of truth

Problem 1 is a capability problem (I can't work around this). Problem 2 is a usability and stability problem.

Problem 1: Cannot observe effects of Command::env_clear

As documented in my merged PR, calling env_clear will cause the "capture" logic to skip inheriting from the parent process. While a developer can set this value, they cannot programmatically observe if it has been set (though they can manually observe it via "alternative" debug formatting {:#?}).

The end goals of programmatically observing that effect is listed below in "Motivation".

This problem prevents me from reproducing the capture logic in a library where I cannot observe if env_clear has been called. Even if this problem is solved, a related problem is detailed below.

Problem 2: The "capture" logic has no exposed single source of truth

Even if we could directly observe the effects of calling env_clear, every developer who needed this information would need to reproduce this "capture" logic https://github.com/rust-lang/rust/blob/3a5c8e91f094bb1cb1346651fe3512f0b603d826/library/std/src/sys_common/process.rs#L36-L51 and hope that it does not change or that their implementation does not diverge.

If the outcome of this logic is exposed, then it will:

  • Solve problem 1
  • Prevent divergent reimplementation of "capture" logic

Out of scope

Like other system resources (such as files on disk), environment variables may be modified between the time of check and use. This TOCTOU problem is out of the scope of my motivation for opening this issue. If you have that problem, please open a separate issue.

Motivation, use-cases

I want to use a Command struct as the single point of truth for what will run in the future or what already ran, including environment variables.

Motivation: Context

I maintain the Ruby buildpack, written in Rust. The job of a buildpack is to take in a user's application, perform work, and then output an OCI image. A core part of this work is running system commands. Such as bundle install and rake assets:precompile. Other buildpacks target other ecosystems: nodejs, python, PHP, go, and Java.

As part of providing this experience, I need to:

  • To advertise what command is being run before it runs.
  • Give a user detailed information when a command unexpectedly fails after it runs.

To do that with Rust, I want to:

  • Programmatically displaying a command's environment variables before it executes for an end-user.
  • Programmatically adding system information after a failed command execution for an end-user (e.g., which_problem)

I cannot accomplish these while using & mut Command as a single point of truth today. See below for more details.

Motivation: Programmatically displaying a command's environment variables before it executes for an end-user.

Commands like rake assets:precompile depend on environment variables for configuration (like RAILS_ENV=production). It's important that I can display the command before it executes because:

  • If it freezes, users need to be able to try to reproduce the freeze
  • If it fails, users will try to reproduce the failure

To that end, I want my user to see an output in their build log like:

Running: $ RAILS_ENV=production bin/rake assets:precompile

While I can provide this type of output for my users today, I cannot generalize it into a library for other buildpacks while only consuming &mut Command without introducing another point of truth.

Here's an example function:

// This code is invalid as it cannot determine the actual env state.
//
// Either we can assume `env_clear` was called, and only explicit
// values should be used, or assume it wasn't called
// and then implement the env var inheritance logic manually and
// hope there's no mismatch between our logic and Rust's.
//
pub fn command_name_with_env(
    cmd: &mut Command,
    keys: impl IntoIterator<Item = impl Into<OsString>>,
) -> String {
    let env = cmd
        .get_envs()
        .filter_map(|(k, v)| v.map(|value| (k.to_os_string(), value.to_os_string())))
        .collect::<Vec<(OsString, OsString)>>();

    display_with_keys(cmd, env, keys)
}

You could call it like:

command_name_with_env(&command, ["RAILS_ENV"])

Motivation: Programmatically adding system information after a failed command execution for an end-user (e.g. which_problem)

I want to be able to debug PATH issues on a Command. The developer may accidentally call env_clear and fail to realize that it also removes PATH, or they might think they added something to the PATH but had a typo.

To help debug those cases, I wrote a library that performs a which lookup and adds debugging information which_problem crate. I want to use it to debug when a command has failed.

Something like this:

use std::ffi::OsString;
use std::process::Command;
use which_problem::Which;

fn main() {
    println!("Hello, world!");

    let mut command = Command::new("bundle");
    command.arg("install");
    let output = command.output();

    output
        .map_err(|error| {
            eprintln!("Executing command '{command:?}' failed.\nError: {error}");

            match which_problem_from_command(&command).diagnose() {
                Ok(details) => println!("Diagnostic info: {details}"),
                Err(error) => println!("Warning: Internal which_problem error: {error}"),
            }
            error
        })
        .unwrap();
}

// This is not valid as it cannot determine the actual env state.
//
// Either we can assume `env_clear` was called, and only explicit
// values should be used, or we have to assume it wasn't called
// and then implement the env var inheritance logic manually and
// hope there's not a mismatch between our logic and Rust's
//
fn which_problem_from_command(command: &Command) -> which_problem::Which {
    let cwd = command.get_current_dir().map(|path| path.to_path_buf());
    let program = command.get_program().to_owned();
    let path_env = command
        .get_envs()
        .find_map(|(key, maybe_value)| maybe_value.filter(|_| key == &OsString::from("PATH")))
        .map(|value| value.to_owned());

    Which {
        cwd,
        program,
        path_env,
        ..Default::default()
    }
}

However, the code must assume something about the state of Command as it cannot be directly observed. Giving the wrong path information to the person debugging will actively harm the debugging experience.

Solution sketches

A quick note on naming things: I've stubbed in names for methods, but I'm not tied to any of them.

Internally the combination of explicit and implicit env vars are retrieved via a capture or captured method. These are described as the env vars that will be captured (or were previously) by the command.

I've also considered the following:

  • canonical (like used for paths).
  • resolved
  • finalized

They're used interchangeably below.

[sketch] Expose captured env logic via a new captured_envs method on Command

Update: A sample implementation of this is here

We could add a method that exposes that information directly:

let command = Command::new("ls");

let envs: HashMap<OsString, OsString> = command.captured_envs().collect();

This sketch would solve problems 1 and 2. I'm open to naming suggestions.

[sketch] Expose captured env logic via a pure function that takes Command and VarOs or an iterator

For example:

/// General idea, not proposing this exact code, I didn't compile this
///
pub fn capture_envs(command: &Command, inherited: impl IntoIter<(OsString, OsString)>) -> CapturedEnvs {
        let mut result = BTreeMap::<EnvKey, OsString>::new();
        if !command.is_env_clear {
            for (k, v) in inherited {
                result.insert(k.into(), v);
            }
        }
        for (k, maybe_v) in &command.vars {
            if let &Some(ref v) = maybe_v {
                result.insert(k.clone(), v.clone());
            } else {
                result.remove(k);
            }
        }
        CapturedEnvs { result.into_iter() }
    }

The benefit of this approach is that it's now a pure function and does not rely on env::vars_os() call to the current environment. For this to work, we would also have to expose Command.is_env_clear internal state.

This sketch would solve problems 1 and 2. I'm open to naming suggestions.

[sketch] Expose the clear boolean state

If I had a flag on whether or not clear had been called on the Command struct, I would have enough information to resolve environment variables manually. Something like:

command.is_env_clear();
command.get_env_clear();

Internally the state is preserved on CommandEnvs, so another option could be to mirror the internal state and expose it on the iterator:

command.get_envs().is_clear()

This sketch would solve problem 1 but not 2. The end programmer must still reimplement the environment variable capture logic, which may diverge. I'm open to naming suggestions.

Even if we choose another solution, we may want to consider exposing this information anyway, as all other explicitly set states on Command can be queried except for this one.

[sketch] Expose captured env logic via method chain method on Command

As an alternative to the above Command::captured_envs method, we could make the information chainable:

let command = Command::new("ls");

let envs: HashMap<OsString, OsString> = command.get_envs().capture();

Like the above chained command.get_envs().is_clear(), this maps somewhat to the internal representation.

Benefits: The methods of Command are not changed.
Downsides: More OOP than functional style. It removes the ability to use Command::captured_envs in a chain, like calling map on an option.

This sketch solves problems 1 and 2. I'm open to naming suggestions.

[sketch] Something else

Any ideas?

[sketch] Deprecate get_envs and introduce a new interface.

Deprecate get_envs and introduce other APIs. Motivation is provided below. An example could be:

command.get_removed_envs()
command.get_explicit_envs()
command.get_captured_envs()

While this idea is a stretch, I wanted to document that it was possible (in brainstorming, quantity leads to quality) and give some additional context on the existing interface.

When I first saw get_envs, I assumed it was referring to getting the state that would be set when it was run rather than the explicit state that must be resolved.

I was not paying close attention when I first encountered get_envs. I guessed what I thought it meant (and was REALLY wrong).

In the case of a None value, it doesn't mean:

"None was given, fallback to a parent's value."

It means:

"Someone explicitly told me to unset this singular env var and not fallback."

That behavior is the opposite of what I assumed.

While it's ultimately my mistake to own, I want to explore how we could improve that experience and provide a new interface for the captured/canonical/resolved envs.

I introduced a documentation PR and added some wording in the header, specifically "explicitly," which I hope will catch people's attention. rust-lang/rust#109272, though there's still behavior inconsistency. Of the get methods, these have no ambiguity:

  • get_args
  • get_program

While these have some:

  • get_envs
  • get_current_dir

I've talked at length about get_envs. The other, get_current_dir, returns either Some if an explicit value was set or None if it wasn't. I'm not personally surprised by what happens when a None is seen, but I'm not all users. It is confusing that None for get_current_dir and None for get_envs means opposite things.

Links and related work

What happens now?

This issue is part of the libs-api team API change proposal process. Once this issue is filed the libs-api team will review open proposals in its weekly meeting. You should receive feedback within a week or two.

Metadata

Metadata

Assignees

No one assigned

    Labels

    T-libs-apiapi-change-proposalA proposal to add or alter unstable APIs in the standard libraries

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions