cargo / inquire / audit
cargo : inquire @ 0.7.5
PE Patrick Elsen signed 2026-05-27 published 2026-05-27

Claims

concurrency-documentedconcurrency-safeenvironment-safeexec-safefilesystem-safehas-binarieshas-build-exechas-fuzz-testshas-install-exechas-integration-testshas-property-testshas-unit-testsimpl-algorithmimpl-concurrencyimpl-cryptoimpl-datastructureimpl-interpreterimpl-jitimpl-parserimpl-protocolis-benignuses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

inquire 0.7.5 is an interactive CLI prompt library (Text, Password, Confirm, Select, MultiSelect, DateSelect, Editor). Written in safe Rust with no unsafe blocks, no FFI, and no network I/O. One medium-severity finding: the Password prompt does not zeroize input on clear or drop, leaving password bytes accessible in heap memory until overwritten by the allocator.

Report

Subject

inquire 0.7.5 is an interactive terminal prompt library for Rust CLI applications. It exposes eight prompt types: Text (free-form input with optional autocompletion), Password (masked input with optional confirmation), Confirm (yes/no), Select and MultiSelect (option lists), CustomType (text input parsed to an arbitrary type), DateSelect (calendar picker, date feature), and Editor (long-form input via an external editor, editor feature). Three interchangeable terminal backends are available: crossterm (default, cross-platform), termion (Unix), and console.

Methodology

The published crate contents were compared against the upstream Git repository at the commit recorded in .cargo_vcs_info.json using diff -rq. All source files in contents/src/ (approximately 14,500 LOC across 60 files) were read in full. Initial surveys were run with grep to locate unsafe blocks, FFI declarations, network calls, filesystem calls, process invocations, environment variable reads, concurrency primitives, and cryptographic operations. The VCS checkout was present and complete.

Results

The diff between published contents and VCS shows only the expected cargo-normalised Cargo.toml divergence; all source files match byte-for-byte. No binary artifacts are present (has-binaries=false). There is no build.rs and no proc-macro declaration, so no build-time code executes (has-build-exec=false, has-install-exec=false).

No unsafe blocks, FFI declarations, or raw-pointer operations appear anywhere in the source (uses-unsafe=false). No network operations are present (uses-network=false). No cryptographic libraries are imported or invoked (uses-crypto=false, impl-crypto=false). The crate uses no JIT compiler and embeds no interpreter (uses-jit=false, uses-interpreter=false, impl-jit=false, impl-interpreter=false). The crate does not implement its own parser, protocol, data structure, algorithm, or concurrency primitives (impl-parser=false, impl-protocol=false, impl-datastructure=false, impl-algorithm=false, impl-concurrency=false).

The Editor prompt spawns an external process via process::Command::new with an argv array (never via a shell), justifying uses-exec=true and exec-safe=true. The editor command is resolved from the EDITOR/VISUAL environment variables or defaults to nano/notepad; the NO_COLOR environment variable is read for color output configuration. These are conventional, documented variables; the environment is not enumerated or exfiltrated, justifying uses-environment=true and environment-safe=true. The Editor prompt creates a NamedTempFile via the tempfile crate with a random name; filesystem access is limited to this controlled path, justifying uses-filesystem=true and filesystem-safe=true.

A global Mutex<RenderConfig<'static>> is guarded by once_cell::sync::Lazy; access is through two documented public functions. No other shared mutable state was found. Justifies uses-concurrency=true, concurrency-safe=true, and concurrency-documented=true.

The codebase contains 119 unit tests across the prompt modules (covering input handling, confirm, select, multiselect, date, password, and text prompt logic) but no integration tests, fuzz tests, or property tests, justifying has-unit-tests=true, has-integration-tests=false, has-fuzz-tests=false, and has-property-tests=false.

One medium-severity security finding (FINDING-1) was identified: the Password prompt accumulates typed characters in a String via the Input type, and clears them with String::clear() when clearing on validation failure or confirmation mismatch, and returns the final password value as a plain String. Neither Input nor the returned String is zeroed on drop. No zeroize dependency is present. Password bytes can persist in heap memory until the allocator overwrites the region, making them potentially recoverable from core dumps or through memory forensics. This is a commonly expected property for password-entry libraries that is absent here.

No malicious, obfuscated, or suspicious code was found; is-benign=true.

Conclusion

The codebase is written entirely in safe Rust with no unsafe blocks, no FFI, no network I/O, and no cryptographic operations. The one notable concern is the absence of memory zeroization for password input, documented in FINDING-1. All other prompt types behave as documented. The 119 unit tests provide reasonable coverage of the input handling and prompt logic, but there are no fuzz tests or property tests.

Findings(1)

FINDING-1 security medium

Password input not zeroized on drop or clear

The Password prompt stores user input in an Input struct backed by a String (src/input/mod.rs). When the prompt clears input on validation failure or confirmation mismatch (e.g., self.input.clear() at src/prompts/password/prompt.rs:175,190), it calls String::clear(), which sets the length to zero but does not overwrite the backing heap allocation with zeros. The password bytes remain in heap memory until the allocator reclaims or overwrites the region.

The final password String returned to the caller from submit() (src/prompts/password/prompt.rs:186) likewise carries no Drop implementation that zeroes the content. Neither the Input type nor the Password prompt depend on the zeroize crate or any equivalent.

This means password bytes can linger in process memory until overwritten, making them potentially readable via a heap inspection (e.g., core dumps, memory forensics, or a bug in another component that reads freed heap pages). This is a well-known hazard for password-entry libraries.

Justifies uses-unsafe=false (the issue is in safe Rust, no unsafe is present, so there is no unsafe claim to assert false — this finding documents the security concern without a corresponding unsafe claim).

Annotations(4)

src/config.rs

A global Mutex<RenderConfig<'static>> is initialized via once_cell::sync::Lazy in src/config.rs. Access is through two public functions: get_configuration() which acquires a read lock and copies the value, and set_global_render_config() which acquires a write lock and replaces it. Both use unwrap() on lock acquisition, which panics if the mutex is poisoned. No other shared mutable state was found. Justifies uses-concurrency=true and concurrency-safe=true. The public functions' behavior is documented in their doc comments, justifying concurrency-documented=true.

src/prompts/editor/mod.rs

src/prompts/editor/mod.rs, line 239-248

    if let Ok(editor) = env::var("EDITOR") {
        if !editor.is_empty() {
            default_editor = editor;
        }
    }

    if let Ok(editor) = env::var("VISUAL") {
        if !editor.is_empty() {
            default_editor = editor;
        }

Two environment variables are read: EDITOR and VISUAL in get_default_editor_command() (src/prompts/editor/mod.rs:239-248) to select the default editor command. The NO_COLOR variable is read in RenderConfig::default() (src/ui/api/render_config.rs:333) to disable terminal color output. All three are documented variables with conventional meanings; no enumeration or exfiltration of the environment occurs. Justifies uses-environment=true and environment-safe=true.

src/prompts/editor/prompt.rs

src/prompts/editor/prompt.rs, line 63-69

    fn run_editor(&mut self) -> InquireResult<()> {
        process::Command::new(&self.config.editor_command)
            .args(&self.config.editor_command_args)
            .arg(self.tmp_file.path())
            .spawn()?
            .wait()?;

The Editor prompt spawns the user's configured editor via process::Command::new(&self.config.editor_command).args(&self.config.editor_command_args).arg(self.tmp_file.path()). The editor command is taken from the editor_command field of EditorConfig, which defaults to nano/notepad and can be overridden by the caller or by reading the EDITOR/VISUAL environment variables. Arguments are passed as an argv array (not via shell), so no shell injection is possible. Justifies uses-exec=true and exec-safe=true.

src/prompts/editor/prompt.rs, line 45-60

    fn create_file(
        file_extension: &str,
        predefined_text: Option<&str>,
    ) -> std::io::Result<NamedTempFile> {
        let mut tmp_file = tempfile::Builder::new()
            .prefix("tmp-")
            .suffix(file_extension)
            .rand_bytes(10)
            .tempfile()?;

        if let Some(predefined_text) = predefined_text {
            tmp_file.write_all(predefined_text.as_bytes())?;
            tmp_file.flush()?;
        }

        Ok(tmp_file)

The Editor prompt creates a NamedTempFile via the tempfile crate using a prefix of "tmp-", a random 10-byte suffix, and a configurable extension. The path is never exposed to untrusted input. Reads from the file are done via fs::read_to_string(self.tmp_file.path()), which is a controlled path. No path traversal risk is present. Justifies uses-filesystem=true and filesystem-safe=true.

src/prompts/password/prompt.rs

src/prompts/password/prompt.rs, line 1-267

use crate::{
    error::InquireResult,
    formatter::StringFormatter,
    input::Input,
    prompts::prompt::{ActionResult, Prompt},
    ui::PasswordBackend,
    validator::{ErrorMessage, StringValidator, Validation},
    InquireError, Password, PasswordDisplayMode,
};

use super::{action::PasswordPromptAction, config::PasswordConfig};

// Helper type for representing the password confirmation flow.
struct PasswordConfirmation<'a> {
    // The message of the prompt.
    pub message: &'a str,

    // The error message of the prompt.
    pub error_message: &'a str,

    // The input to confirm.
    pub input: Input,
}

pub struct PasswordPrompt<'a> {
    message: &'a str,
    config: PasswordConfig,
    help_message: Option<&'a str>,
    input: Input,
    current_mode: PasswordDisplayMode,
    confirmation: Option<PasswordConfirmation<'a>>, // if `None`, confirmation is disabled, `Some(_)` confirmation is enabled
    confirmation_stage: bool,
    formatter: StringFormatter<'a>,
    validators: Vec<Box<dyn StringValidator>>,
    error: Option<ErrorMessage>,
}

impl<'a> From<Password<'a>> for PasswordPrompt<'a> {
    fn from(so: Password<'a>) -> Self {
        let confirmation = match so.enable_confirmation {
            true => Some(PasswordConfirmation {
                message: so.custom_confirmation_message.unwrap_or("Confirmation:"),
                error_message: so
                    .custom_confirmation_error_message
                    .unwrap_or("The answers don't match."),
                input: Input::new(),
            }),
            false => None,
        };

        Self {
            message: so.message,
            config: (&so).into(),
            help_message: so.help_message,
            current_mode: so.display_mode,
            confirmation,
            confirmation_stage: false,
            formatter: so.formatter,
            validators: so.validators,
            input: Input::new(),
            error: None,
        }
    }
}

impl<'a> From<&'a str> for Password<'a> {
    fn from(val: &'a str) -> Self {
        Password::new(val)
    }
}

impl<'a> PasswordPrompt<'a> {
    fn active_input_mut(&mut self) -> &mut Input {
        if let Some(c) = &mut self.confirmation {
            if self.confirmation_stage {
                return &mut c.input;
            }
        }

        &mut self.input
    }

    fn toggle_display_mode(&mut self) -> ActionResult {
        let new_mode = match self.current_mode {
            PasswordDisplayMode::Hidden | PasswordDisplayMode::Masked => PasswordDisplayMode::Full,
            PasswordDisplayMode::Full => self.config.display_mode,
        };

        if new_mode != self.current_mode {
            self.current_mode = new_mode;
            ActionResult::NeedsRedraw
        } else {
            ActionResult::Clean
        }
    }

    fn confirmation_step(&mut self) -> ConfirmationStepResult {
        let cur_answer = self.cur_answer().to_owned();
        match &mut self.confirmation {
            None => ConfirmationStepResult::NoConfirmationRequired,
            Some(confirmation) => {
                if self.confirmation_stage {
                    if cur_answer == confirmation.input.content() {
                        ConfirmationStepResult::ConfirmationValidated
                    } else {
                        self.confirmation_stage = false;
                        confirmation.input.clear();
                        ConfirmationStepResult::ConfirmationInvalidated(ErrorMessage::Custom(
                            confirmation.error_message.to_owned(),
                        ))
                    }
                } else {
                    confirmation.input.clear();
                    self.confirmation_stage = true;

                    ConfirmationStepResult::ConfirmationPending
                }
            }
        }
    }

    fn validate_current_answer(&self) -> InquireResult<Validation> {
        for validator in &self.validators {
            match validator.validate(self.cur_answer()) {
                Ok(Validation::Valid) => {}
                Ok(Validation::Invalid(msg)) => return Ok(Validation::Invalid(msg)),
                Err(err) => return Err(InquireError::Custom(err)),
            }
        }

        Ok(Validation::Valid)
    }

    fn cur_answer(&self) -> &str {
        self.input.content()
    }
}

impl<'a, Backend> Prompt<Backend> for PasswordPrompt<'a>
where
    Backend: PasswordBackend,
{
    type Config = PasswordConfig;
    type InnerAction = PasswordPromptAction;
    type Output = String;

    fn message(&self) -> &str {
        self.message
    }

    fn config(&self) -> &PasswordConfig {
        &self.config
    }

    fn format_answer(&self, answer: &String) -> String {
        (self.formatter)(answer)
    }

    fn pre_cancel(&mut self) -> InquireResult<bool> {
        if let Some(confirmation) = &mut self.confirmation {
            if self.confirmation_stage {
                confirmation.input.clear();
                self.confirmation_stage = false;
                return Ok(false);
            }
        }

        Ok(true)
    }

    fn submit(&mut self) -> InquireResult<Option<String>> {
        if let Validation::Invalid(msg) = self.validate_current_answer()? {
            self.error = Some(msg);
            if self.config.display_mode == PasswordDisplayMode::Hidden {
                self.input.clear();
            }
            return Ok(None);
        }

        let confirmation = self.confirmation_step();

        let cur_answer = self.cur_answer().to_owned();

        let result = match confirmation {
            ConfirmationStepResult::NoConfirmationRequired
            | ConfirmationStepResult::ConfirmationValidated => Some(cur_answer),
            ConfirmationStepResult::ConfirmationPending => None,
            ConfirmationStepResult::ConfirmationInvalidated(message) => {
                self.error = Some(message);
                self.input.clear();
                None
            }
        };

        Ok(result)
    }

    fn handle(&mut self, action: PasswordPromptAction) -> InquireResult<ActionResult> {
        let result = match action {
            PasswordPromptAction::ValueInput(input_action) => {
                self.active_input_mut().handle(input_action).into()
            }
            PasswordPromptAction::ToggleDisplayMode => self.toggle_display_mode(),
        };

        Ok(result)
    }

    fn render(&self, backend: &mut Backend) -> InquireResult<()> {
        if let Some(err) = &self.error {
            backend.render_error_message(err)?;
        }

        match self.current_mode {
            PasswordDisplayMode::Hidden => {
                backend.render_prompt(self.message)?;

                match &self.confirmation {
                    Some(confirmation) if self.confirmation_stage => {
                        backend.render_prompt(confirmation.message)?;
                    }
                    _ => {}
                }
            }
            PasswordDisplayMode::Masked => {
                backend.render_prompt_with_masked_input(self.message, &self.input)?;

                match &self.confirmation {
                    Some(confirmation) if self.confirmation_stage => {
                        backend.render_prompt_with_masked_input(
                            confirmation.message,
                            &confirmation.input,
                        )?;
                    }
                    _ => {}
                }
            }
            PasswordDisplayMode::Full => {
                backend.render_prompt_with_full_input(self.message, &self.input)?;

                match &self.confirmation {
                    Some(confirmation) if self.confirmation_stage => {
                        backend.render_prompt_with_full_input(
                            confirmation.message,
                            &confirmation.input,
                        )?;
                    }
                    _ => {}
                }
            }
        }

        if let Some(message) = self.help_message {
            backend.render_help_message(message)?;
        }

        Ok(())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfirmationStepResult {
    NoConfirmationRequired,
    ConfirmationPending,
    ConfirmationValidated,
    ConfirmationInvalidated(ErrorMessage),
}

The Password prompt stores typed characters in Input::content: String. When clearing on failed validation or confirmation mismatch, String::clear() is called, which sets the length to zero without overwriting the heap allocation. No zeroize call or Drop that zeroes the backing buffer is present. See FINDING-1.