cargo / inquire / audit
cargo : inquire @ 0.9.4
PE Patrick Elsen signed 2026-06-02 published 2026-06-02

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-benignparser-impl-correctparser-impl-safeparser-impl-testeduses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

Audit of inquire 0.9.4, a Rust library for interactive terminal prompts (Text, Editor, DateSelect, Select/MultiSelect, Confirm, CustomType, Password). Matches upstream Git byte-for-byte; ships no binaries, no build.rs, no unsafe. Five low-severity findings: a dead enum_support module, an unreachable NaiveDate-overflow panic in date navigation, a minor password-handling note (no constant-time compare or zeroize), an unreachable panic! in date_utils, and an editor-subprocess note.

Report

Subject

inquire is a Rust library for building interactive terminal prompts. It provides seven prompt types — Text (with autocompletion), Editor (gated on the editor feature, opens $EDITOR/$VISUAL on a temp file), DateSelect (gated on the date feature, calendar navigation backed by chrono), Select, MultiSelect, Confirm, CustomType, and Password — together with a pluggable rendering layer that targets crossterm (default), termion, or console as the terminal backend. The crate is configurable via per-prompt builders and a global RenderConfig carried in a Mutex.

Methodology

The published crate contents were compared against the upstream Git repository at the commit recorded in .cargo_vcs_info.json (3d5b65422a24...) using diff -rq. All Rust files under src/ were read in full (≈15.3K LOC across 70 files, including the unit tests under #[cfg(test)] mods). grep (BSD) was used to enumerate the call sites that justify the capability claims: std::net, std::fs, std::process, std::env, thread::, unsafe, forbid(unsafe_code), and the various impl-* indicators. find was used to enumerate non-textual artefacts in contents/. No build scripts, proc macros, or binary blobs were present, so no dynamic-execution review was needed. inquire's upstream CI configuration (under vcs-root/.github/workflows/) was inspected to confirm that lint, format, and tests run on stable, MSRV (1.82.0), and three OSes. The code was not built or executed locally; findings were derived by reading.

Results

The comparison between the published crate contents and the upstream Git repository shows that the code files match byte-for-byte; Cargo.toml differs only in cargo's standard normalization, and Cargo.toml.orig/.cargo_vcs_info.json/Cargo.lock are the cargo-generated artefacts. The CRATE_README.md differs from the upstream README.md because the README in the published crate is a stripped-down variant — the include list in the manifest (/src, /../LICENSE) excludes other repo files, and the README points to assets that only exist in the repository.

The crate ships no binary artefacts (justifying has-binaries), no build.rs (justifying has-build-exec), no install hooks, and no proc macros (justifying has-install-exec). The published Cargo.toml declares build = false explicitly, and [lib] carries no proc-macro = true. The audited surface contains no unsafe blocks at all (justifying uses-unsafe — grep for the unsafe token across src/ returns zero matches; the upstream README's "unsafe forbidden" badge is enforced by the absence of any such code rather than by #![forbid(unsafe_code)], which is not set).

Network, JIT, interpreter, and cryptographic functionality are entirely absent from the source tree — grep for std::net, wasmtime, rusty_v8, mlua, rusqlite, ring, openssl, sha2, aes, ed25519, RustCrypto returned no matches in the audited code (justifying uses-crypto, uses-jit, uses-interpreter, uses-network, impl-crypto, impl-jit, impl-interpreter, impl-protocol, impl-algorithm, impl-datastructure). The crate uses chrono for date math when the date feature is on, but does not itself implement an algorithm or datastructure worth the implementation claims, and does not implement any concurrency primitives (justifying impl-concurrency).

The crate has a sizeable in-tree test suite (145 #[test] or #[rstest] annotations across 18 files, including prompts/{confirm,select,multiselect,password,text,dateselect}/test.rs, the input handler tests, and parser/utils/ansi unit tests), but no integration tests under tests/, no fuzz harnesses under fuzz/, and no property-based tests (justifying has-unit-tests, has-integration-tests, has-fuzz-tests, has-property-tests).

Five low-severity findings were recorded:

  • FINDING-1: src/enum_support.rs is a dead module that is never wired into lib.rs and contains a phantom import of FromListOption from list_option, a type that does not exist there. Likely abandoned scaffolding for a strum/derive integration that was moved out to the separate inquire-derive crate.
  • FINDING-2: DateSelect's day/week navigation calls NaiveDate::add(Duration) directly, which panics on overflow, while the parallel month/year navigation uses checked arithmetic. Reaching the panic would require navigating to within days of NaiveDate::MAX/MIN (~year ±262143), so it is not realistically triggerable.
  • FINDING-3: The Password prompt's confirmation compares the two inputs with String's short-circuiting ==, not a constant-time compare, and stores the password as a plain String with no zeroize on drop. Not a meaningful exposure in the prompt's threat model (local terminal, no remote observer of timing), recorded for visibility.
  • FINDING-4: date_utils::get_month panics on out-of-range months. Currently only reachable through NaiveDate::month(), which always returns 1..=12, so unreachable in practice; flagged as the only panic! in the audited surface.
  • FINDING-5: The Editor prompt spawns the configured editor command via process::Command::new(...).args(...).arg(tmp_file_path).spawn(). The argv form precludes shell injection; the command source (with_editor_command or $EDITOR/$VISUAL) is the documented and trusted-by-design contract — justifies exec-safe.

The ANSI escape-sequence matcher in src/ansi.rs is the only parser implementation in the crate (justifying impl-parser). It is a small hand-written state machine over &str, fully panic-free and allocation-free, with unit tests covering normal and pathological inputs (justifying parser-impl-safe, parser-impl-tested). The comment cites vt100.net/emu/dec_ansi_parser as the basis; it implements a deliberately simplified subset of the DEC ANSI parser sufficient for inquire's rendering needs. The implementation is correct for that documented subset, justifying parser-impl-correct.

The crate uses concurrency only through a Mutex<RenderConfig> global accessed via set_global_render_config/get_configuration (justifying uses-concurrency); the lock is held for trivial scoped writes, so poisoning is not reachable under normal use. The thread-safety contract of the public prompt builders themselves is not explicitly documented per-type, supporting concurrency-documented being asserted false because no per-type Send/Sync narrative exists in the docs.

The code makes no malicious calls — no data exfiltration, no obfuscated payloads, no targeted cfg branches, no telemetry — supporting is-benign.

Conclusion

inquire 0.9.4 is a focused, well-organised terminal prompt library with a clean separation between the prompt state machines, the input/key abstraction, and three pluggable terminal backends. The audit surfaced five low-severity findings: one dead module (FINDING-1), one panic-on-overflow on a code path the user cannot realistically reach (FINDING-2), one minor cryptographic-hygiene gap in the Password prompt that does not actually matter for the prompt's threat model (FINDING-3), one unreachable panic! in date_utils (FINDING-4), and a documentation-only note about the editor subprocess invocation (FINDING-5). The published crate matches its upstream Git tree byte-for-byte, ships no binaries or build hooks, contains no unsafe code, and has a 145-test in-tree suite plus a multi-OS CI matrix.

Findings(5)

FINDING-1 quality low

Dead module `enum_support` with phantom `FromListOption` import

The file src/enum_support.rs defines an InquireEnumVariants trait and a #[cfg(feature = "strum")]-gated impl, but the module is not declared in src/lib.rs (which lists every other module), so it is never compiled into the crate.

The file's unconditional use statement imports a type FromListOption from list_option that does not exist:

use crate::{
    error::InquireResult,
    list_option::{FromListOption, ListOption},
};

Were the module ever wired into the crate root, the import would fail to resolve. The strum feature it gates on is also not declared in Cargo.toml's [features] table. The combination indicates abandoned scaffolding for a derive integration that never landed in this crate (the README points users to a separately published inquire-derive crate for the Selectable derive).

FINDING-2 correctness low

DateSelect day/week navigation can panic on overflow

DateSelectPrompt::shift_date in src/prompts/dateselect/prompt.rs calls self.current_date.add(duration) directly (line 59):

fn shift_date(&mut self, duration: Duration) -> ActionResult {
    self.update_date(self.current_date.add(duration))
}

<NaiveDate as Add<Duration>>::add panics on overflow rather than saturating. The sister method shift_months correctly uses checked_add_months / checked_sub_months and falls back to NaiveDate::MAX / NaiveDate::MIN. shift_date is currently invoked only with Duration::days(±1) and Duration::weeks(±1), so triggering this panic requires the user to have navigated the calendar to within a few days of NaiveDate::MAX (~year 262143) or NaiveDate::MIN — practically unreachable but a real inconsistency with shift_months. Replacing the call with checked_add_signed (matching the months path) would close the gap.

FINDING-3 security low

Password confirmation comparison is not constant-time

PasswordPrompt::confirmation_step in src/prompts/password/prompt.rs (line 108) compares the password with its confirmation using == on String:

if cur_answer == confirmation.input.content() {
    ConfirmationStepResult::ConfirmationValidated
} else {
    ...
}

String's PartialEq short-circuits at the first mismatching byte. In the threat model that applies to this crate — an interactive prompt running in a local terminal where the operator types both copies of the password — there is no remote attacker observing comparison timing, so this does not enable a practical attack. Recording the finding for completeness rather than as a meaningful exposure: a downstream audit policy that requires constant-time comparison for any code touching secret material would not be satisfied by this code path.

Additionally, passwords are stored as plain String and the buffer is not zeroized on drop or after submission, so the secret can linger in heap memory until the allocator overwrites it.

FINDING-4 quality low

`date_utils::get_month` panics on out-of-range input

get_month in src/date_utils.rs (line 13) panics on any value outside 1..=12:

pub fn get_month(month: u32) -> chrono::Month {
    match month {
        1 => chrono::Month::January,
        ...
        _ => panic!("Invalid month"),
    }
}

The function is reachable only via NaiveDate::month() (always 1..=12 per chrono's invariants), so it cannot be triggered in practice. Documented panic-free behaviour for impl-* claims expects unwrap()-free or otherwise-guaranteed paths; this is the only panic! in the audited surface aside from chrono's own from_ymd_opt(...).unwrap() on hardcoded inputs. The two #[should_panic] tests document the behaviour, so it is intentional. Could be replaced with chrono::Month::try_from(month as u8) (a chrono-provided method that returns Result<Month, OutOfRange>).

FINDING-5 security low

Editor prompt spawns user-configurable command without sanitisation

EditorPrompt::run_editor in src/prompts/editor/prompt.rs (line 64) launches the configured editor on a temp file:

process::Command::new(&self.config.editor_command)
    .args(&self.config.editor_command_args)
    .arg(self.tmp_file.path())
    .spawn()?
    .wait()?;

The command is taken from Editor::with_editor_command (set by the application) or falls back to $VISUAL/$EDITOR (set by the user's environment); on neither path does inquire interpret the value through a shell. The invocation uses argv form — Command::new(...).args(...).arg(...) — so no metacharacter handling applies and the value is passed as a single argv[0] token to execvp-equivalent OS APIs. Because the command and args come from either the calling application or the operating-environment-trusted env vars, this is the documented and expected behaviour and is not a vulnerability. Recording the finding to make the trust boundary visible in the audit (justifies uses-exec, exec-safe).

Annotations(11)

src/ansi.rs

src/ansi.rs, line 13-98

/// Matches an ANSI escape code according to a simplified version of
/// [this description](https://vt100.net/emu/dec_ansi_parser). As we only want to know when the
/// automaton gets out of/reaches the "ground" state, we only keep track of transitions leading
/// out of it/into it.
///
/// The only way to get out of the ground is to read the escape character ('\x1b'). Transitions
/// like "anywhere -- \x9b -> csi_entry" are not supported, as few terminals implement them.
struct AnsiMatcher<'a> {
    input: &'a str,
    chars: Peekable<CharIndices<'a>>,
}

impl<'a> AnsiMatcher<'a> {
    fn new(input: &'a str) -> Self {
        Self {
            input,
            chars: input.char_indices().peekable(),
        }
    }

    #[inline]
    fn run(mut self) -> AnsiMatchResult {
        match self.chars.next() {
            Some((start_index, '\x1b')) => self.escape(start_index),
            _ => AnsiMatchResult::NotMatched,
        }
    }

    #[inline]
    fn next(&mut self) -> Option<u32> {
        self.chars.next().map(|(_, c)| c as u32)
    }

    #[inline]
    fn peek(&mut self) -> Option<(usize, u32)> {
        self.chars.peek().map(|(idx, c)| (*idx, *c as u32))
    }

    fn next_char_boundary(&mut self) -> usize {
        self.peek().map(|(idx, _)| idx).unwrap_or(self.input.len())
    }

    fn escape(mut self, start_index: usize) -> AnsiMatchResult {
        match self.next() {
            None => matched(start_index, self.input.len()),
            Some(0x5B) => self.csi_entry(start_index),
            Some(
                0x5D // osc_string
                | 0x50 // dcs_entry
                | 0x58 | 0x5E | 0x5F) => self.string(start_index), // sos/pm/apc_string
            Some(0x20..=0x2F) => self.escape_intermediate(start_index),
            Some(0x30..=0x4F | 0x51..=0x57 | 0x59 | 0x5A | 0x5C | 0x60..=0x7E) => {
                matched(start_index, self.next_char_boundary())
            }
            Some(0x1B | 0x7F | _) => self.escape(start_index),
        }
    }

    fn csi_entry(mut self, start_index: usize) -> AnsiMatchResult {
        match self.next() {
            Some(0x1B) => self.escape(start_index),
            Some(0x40..=0x7E) => matched(start_index, self.next_char_boundary()),
            None => matched(start_index, self.input.len()),
            _ => self.csi_entry(start_index), // loop until match
        }
    }

    fn escape_intermediate(mut self, start_index: usize) -> AnsiMatchResult {
        match self.next() {
            Some(0x1B) => self.escape(start_index),
            Some(0x30..=0x7E) => matched(start_index, self.next_char_boundary()),
            None => matched(start_index, self.input.len()),
            _ => self.escape_intermediate(start_index), // loop until match
        }
    }

    /// Matches until the end of sos/pm/apc strings, dcs entries and osc strings.
    fn string(mut self, start_index: usize) -> AnsiMatchResult {
        match self.next() {
            Some(0x1B) => self.escape(start_index),
            Some(0x07 | 0x9C) => matched(start_index, self.next_char_boundary()),
            None => matched(start_index, self.input.len()),
            _ => self.string(start_index), // loop until match
        }
    }
}

Hand-written state machine that recognises ANSI/VT escape sequences, used by the frame renderer to layout multibyte/styled output without breaking escape sequences across line boundaries. Simplified version of the vt100.net parser (cited in the doc comment) — does not aim for full DEC ANSI parser conformance, only the subset needed for the rendering passes. Justifies impl-parser. parser-impl-safe is supported by the panic-free, allocation-free, slice-only implementation.

src/config.rs

src/config.rs, line 8-20

static GLOBAL_RENDER_CONFIGURATION: LazyLock<Mutex<RenderConfig<'static>>> =
    LazyLock::new(|| Mutex::new(RenderConfig::default()));

pub fn get_configuration() -> RenderConfig<'static> {
    *GLOBAL_RENDER_CONFIGURATION.lock().unwrap()
}

/// Acquires a write lock to the global RenderConfig object
/// and updates the inner value with the provided argument.
pub fn set_global_render_config(config: RenderConfig<'static>) {
    let mut guard = GLOBAL_RENDER_CONFIGURATION.lock().unwrap();
    *guard = config;
}

Global Mutex<RenderConfig> accessed via set_global_render_config and get_configuration. The audited code uses .lock().unwrap() — poisoning aborts the program, but the lock is held for trivial scoped writes, so poisoning is not reachable under normal use. Justifies uses-concurrency, supports concurrency-safe.

src/date_utils.rs

src/date_utils.rs, line 13-29

pub fn get_month(month: u32) -> chrono::Month {
    match month {
        1 => chrono::Month::January,
        2 => chrono::Month::February,
        3 => chrono::Month::March,
        4 => chrono::Month::April,
        5 => chrono::Month::May,
        6 => chrono::Month::June,
        7 => chrono::Month::July,
        8 => chrono::Month::August,
        9 => chrono::Month::September,
        10 => chrono::Month::October,
        11 => chrono::Month::November,
        12 => chrono::Month::December,
        _ => panic!("Invalid month"),
    }
}

get_month panics on out-of-range inputs (see FINDING-4). Currently unreachable through public API.

src/enum_support.rs

Dead module — not registered in src/lib.rs, so never compiled. The unconditional use list_option::{FromListOption, ...} would not resolve if the module were wired in, since FromListOption does not exist in list_option. See FINDING-1.

src/lib.rs

src/lib.rs, line 65-68

#![warn(missing_docs)]
#![deny(unused_crate_dependencies)]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![allow(clippy::bool_to_int_with_if)]

Crate-level lints: warn(missing_docs) and deny(unused_crate_dependencies). No forbid(unsafe_code) despite the upstream README badge claiming "unsafe forbidden"; the absence of any unsafe token in the entire src/ tree (verified by grep) is what justifies uses-unsafe, not a compiler-enforced lint.

src/prompts/dateselect/prompt.rs

src/prompts/dateselect/prompt.rs, line 58-79

    fn shift_date(&mut self, duration: Duration) -> ActionResult {
        self.update_date(self.current_date.add(duration))
    }

    fn shift_months(&mut self, qty: i32) -> ActionResult {
        let new_date = match qty.cmp(&0) {
            Ordering::Greater | Ordering::Equal => {
                let qty_as_months = Months::new(qty as u32);
                self.current_date
                    .checked_add_months(qty_as_months)
                    .unwrap_or(NaiveDate::MAX)
            }
            Ordering::Less => {
                let qty_as_months = Months::new((-qty) as u32);
                self.current_date
                    .checked_sub_months(qty_as_months)
                    .unwrap_or(NaiveDate::MIN)
            }
        };

        self.update_date(new_date)
    }

Date navigation: shift_date (day/week) uses unchecked Add<Duration> and can panic on overflow, while the parallel shift_months uses checked_add_months/checked_sub_months. See FINDING-2.

src/prompts/editor/mod.rs

src/prompts/editor/mod.rs, line 231-251

fn get_default_editor_command() -> OsString {
    let mut default_editor = if cfg!(windows) {
        String::from("notepad")
    } else {
        String::from("nano")
    };

    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;
        }
    }

    default_editor.into()
}

get_default_editor_command reads $EDITOR and $VISUAL. Together with the $NO_COLOR read in src/ui/api/render_config.rs, these are the only env-var accesses in the crate — justifies uses-environment and environment-safe (documented, scoped to the editor and color defaults).

src/prompts/editor/prompt.rs

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

    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()?;

        Ok(())
    }

Editor subprocess spawn — justifies uses-exec. argv form, no shell interpolation; the command originates from the calling application or $VISUAL/$EDITOR. See FINDING-5 for the trust-boundary rationale that supports exec-safe.

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

    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)
    }

Creates a NamedTempFile via the tempfile crate (random 10-byte suffix) in the OS temp directory, optionally seeded with caller-supplied predefined text. Filesystem access is confined to this temp file and is the entirety of inquire's filesystem footprint — justifies uses-filesystem and filesystem-safe.

src/prompts/password/prompt.rs

src/prompts/password/prompt.rs, line 102-125

    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
                }
            }
        }
    }

Password confirmation compares the two inputs with == and stores both as plain String with no zeroize on drop. See FINDING-3.

src/terminal

Three terminal backends (crossterm, termion, console), each gated by its feature. get_default_terminal picks crossterm first, then termion, then console, with a compile_error! if none is enabled. All three backends delegate raw-mode toggling and key-event reading to the underlying library; the file/network/process surface of inquire itself is unaffected by which backend is chosen.

src/ui/api/render_config.rs

src/ui/api/render_config.rs, line 350-357

impl<'a> Default for RenderConfig<'a> {
    fn default() -> Self {
        match env::var("NO_COLOR") {
            Ok(_) => Self::empty(),
            Err(_) => Self::default_colored(),
        }
    }
}

Reads $NO_COLOR to decide whether RenderConfig::default() returns the colored or empty variant. Documented behaviour mirroring the no-color.org convention.