cargo / toml_edit / audit
cargo : toml_edit @ 0.25.12+spec-1.1.0
PE Patrick Elsen signed 2026-06-02 published 2026-06-02

Claims

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

toml_edit 0.25.12+spec-1.1.0 is a format-preserving TOML parser/editor. No unsafe, no I/O, no concurrency; the byte-level lexer is delegated to toml_parser. Parsing bounds recursion at depth 80 by default; numeric overflow surfaces as TomlError. Tested via the language-neutral toml-test suite, proptests, and an upstream libfuzzer target. One low-severity finding: the unbounded Cargo feature, which disables the recursion guard, is undocumented.

Report

Subject

toml_edit is a format-preserving TOML parser and editor. Unlike a "plain" deserialiser, the crate retains whitespace, comments, and the original ordering of items in a parsed document so that modifications can be written back without churning unrelated formatting. The public API centres on Document / DocumentMut (the root TOML table together with surrounding whitespace), Table / InlineTable (key/value maps backed by indexmap::IndexMap), Array / ArrayOfTables, the Item enum, the scalar Value variants (string, integer, float, boolean, datetime), Key / KeyMut, and the visit/visit_mut traversal traits. Two optional sub-modules expose serde integration: toml_edit::de (deserialise into Rust types) and toml_edit::ser (serialise Rust types into TOML). The crate is a thin edit-preserving layer over the lexer/event-stream in toml_parser; this audit covers only toml_edit itself.

Methodology

Tooling used:

  • openvet audit new (0.6.0) to fetch and unpack the crate from crates.io and clone the upstream multi-crate workspace at the commit recorded in .cargo_vcs_info.json. The workspace is checked out into vcs-root/ with vcs/ symlinked into crates/toml_edit.
  • diff -r (Apple Darwin) to compare published crate contents against the upstream VCS tree.
  • grep to scan contents/src/ for unsafe, extern "C", process::*, std::net::*, std::fs::*, env::*, allocator usage, transmute, panic-prone calls (panic!, expect, unwrap, unimplemented!, unreachable!) and the unbounded/RecursionGuard/LIMIT tokens.
  • Manual reading of the crate-level entry point (src/lib.rs), the parser-dispatch layer (src/parser/mod.rs, src/parser/document.rs, src/parser/value.rs, src/parser/array.rs, src/parser/key.rs), the AST root (src/document.rs), src/encode.rs (display layer), src/raw_string.rs (the span-or-explicit string storage), src/error.rs, src/repr.rs, and the serde integration (src/de/mod.rs, src/de/value.rs, src/ser/mod.rs). The larger AST node modules (src/table.rs, src/inline_table.rs, src/array.rs) were surveyed for panic sites, unsafe blocks and I/O calls; the per-node serde and visitor code was not read end to end.
  • Survey of the upstream test suite (vcs/tests/ — ~2 KLOC of compliance/, serde/, testsuite/edit.rs plus the decoder/encoder compliance harnesses driven by toml-test-data/toml-test-harness) and the upstream toml_edit_fuzz workspace member with its parse_document libfuzzer target.

The published toml_edit-0.25.12+spec-1.1.0.crate was diffed against the upstream repository at the commit pinned in .cargo_vcs_info.json. All files under src/ and examples/ match the upstream tree byte-for-byte; the published crate excludes the multi-crate workspace's other members and the tests/ directory at the workspace level via the include list in Cargo.toml.orig. Cargo's Cargo.toml normalisation is the only manifest-level difference.

Results

The diff between published contents and the upstream repository shows no unexpected changes. The crate contains no binary artefacts (justifying has-binaries) and no build.rs; Cargo.toml declares build = false and [lib] has no proc-macro = true. There is no install-time hook either, justifying has-build-exec and has-install-exec.

A grep across contents/src/ returned zero unsafe blocks, no FFI declarations, no std::net/std::fs/std::process/env:: calls, and no thread- or async-runtime usage. This justifies uses-unsafe, uses-exec, uses-network, uses-filesystem, uses-environment, uses-concurrency, uses-crypto, uses-jit, and uses-interpreter, and the corresponding implementation claims impl-crypto, impl-jit, impl-interpreter, impl-protocol, and impl-concurrency. The Table/InlineTable types are wrappers around indexmap::IndexMap rather than novel data structures, justifying impl-datastructure; the crate implements no compute-heavy algorithms beyond the parser, justifying impl-algorithm.

The crate's central function is parsing TOML into an edit-preserving AST, justifying impl-parser. Three conditional parser claims were evaluated:

  • parser-impl-safe: the lexing and event generation are delegated to toml_parser; the toml_edit layer's parser walks the event stream without unsafe code. On numeric overflow the parser emits a ParseError into the error sink and substitutes a sentinel (f64::NAN, i64::MAX); Document::parse rejects the result if the sink saw any error, so overflow sentinels do not leak into a successful parse. The recursion guard at LIMIT = 80 (in src/parser/mod.rs) and the dotted-key-path depth check (in src/parser/key.rs) bound the stack-depth that an adversarial document can demand of the parser — but only when the unbounded feature is off (see FINDING-1). The panic! / expect sites that exist (~10 across the crate) are all on internal invariants — for example, "non-value item in an array", "span should be in input" — not reachable from a successful parse of any byte string.
  • parser-impl-correct: the upstream tests/decoder.rs / tests/encoder.rs harnesses are driven against the language-agnostic toml-test-data conformance suite via toml-test-harness; an additional tests/compliance/ directory checks invalid-input handling. src/encode.rs contains an embedded proptest that round-trips arbitrary printable strings through value and key parsers.
  • parser-impl-tested: the integration tests under vcs/tests/testsuite/edit.rs (~1900 lines) exercise the editing API in addition to parsing; the upstream workspace member toml_edit_fuzz/parse_document.rs provides a libfuzzer harness for the document parser. Together these justify has-unit-tests, has-integration-tests, has-fuzz-tests, and has-property-tests.

One low-severity quality finding was recorded: FINDING-1 notes that the unbounded feature flag is declared in Cargo.toml without any documentation. Enabling it disables the recursion guard and the dotted-key depth check — a real DoS mitigation for code that parses untrusted TOML — yet no doc comment, no # Crate features section in lib.rs, and no README note flags the trade-off. The default behaviour is safe.

No malicious behaviour was identified, justifying is-benign.

Conclusion

toml_edit is a mature TOML parser/editor with no unsafe code, no I/O, no concurrency, and a focused dependency set anchored in the same upstream workspace (toml_parser, toml_writer, toml_datetime, serde_spanned). The parsing path properly delegates byte-level lexing and escape handling to toml_parser, surfaces overflow errors to the caller, and bounds recursion depth by default. The test suite combines unit tests, integration tests, the language-neutral toml-test conformance suite, proptests, and an upstream libfuzzer target. The one finding is a documentation gap on a security-relevant feature flag.

Findings(1)

FINDING-1 quality low

Undocumented "unbounded" feature disables recursion guard against adversarial input

The published Cargo.toml declares an unbounded feature (line 110) with no documentation in the crate-level docs, no doc comment on the feature in Cargo.toml.orig, and no mention in README.md or lib.rs.

Enabling unbounded removes the toml_parser::parser::RecursionGuard wrapper applied around the receiver in src/parser/mod.rs (LIMIT = 80) for parse_document, parse_key, parse_key_path, and parse_value, and also skips the dotted-key-path depth check in src/parser/key.rs:66-70. With the guard removed, deeply nested arrays, inline tables, or dotted keys in attacker-controlled TOML can exhaust the stack and abort the process.

The default (gate disabled) is safe; the recursion guard is the right default for code that consumes untrusted input. The risk is that a user who turns the feature on — perhaps because they hit the limit on a legitimate but unusual document — will not see any warning that they are opting out of a DoS mitigation. A one-line doc comment on the feature, or a # Crate features section in lib.rs flagging the trade-off, would close this gap.

Annotations(5)

Cargo.toml

Cargo.toml, line 96-114

default = [
    "parse",
    "display",
]
display = ["dep:toml_writer"]
parse = [
    "dep:toml_parser",
    "dep:winnow",
]
serde = [
    "dep:serde_core",
    "toml_datetime/serde",
    "dep:serde_spanned",
]
unbounded = []

[lib]
name = "toml_edit"
path = "src/lib.rs"

unbounded feature is declared as an empty feature with no description. The other features (debug, default, display, parse, serde) are at least named after the functionality they gate; unbounded does not hint at the DoS implication of disabling the recursion guard. See FINDING-1.

src/encode.rs

src/encode.rs, line 402-450

#[cfg(test)]
mod test {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        #[test]
        #[cfg(feature = "parse")]
        fn parseable_string(string in "\\PC*") {
            let value = Value::from(string.clone());
            let encoded = value.to_string();
            let _: Value = encoded.parse().unwrap_or_else(|err| {
                panic!("error: {err}

string:
```
{string}
```
value:
```
{value}
```
")
            });
        }
    }

    proptest! {
        #[test]
        #[cfg(feature = "parse")]
        fn parseable_key(string in "\\PC*") {
            let key = Key::new(string.clone());
            let encoded = key.to_string();
            let _: Key = encoded.parse().unwrap_or_else(|err| {
                panic!("error: {err}

string:
```
{string}
```
key:
```
{key}
```
")
            });
        }
    }
}

Embedded proptest test module: round-trips arbitrary printable strings (\\PC*) through the value parser/encoder, panicking with the offending input on round-trip failure. Justifies has-property-tests for the published crate.

src/lib.rs

src/lib.rs, line 72-77

// https://github.com/Marwes/combine/issues/172
#![recursion_limit = "256"]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs)]
#![warn(clippy::print_stderr)]
#![warn(clippy::print_stdout)]

Crate-level attributes: #![recursion_limit = "256"] (raised to accommodate the macro_rules-heavy parser code), #![warn(missing_docs)] (the crate is fully documented), #![warn(clippy::print_stderr/stdout)] (no stray prints in a library). There is no forbid(unsafe_code) in the source, but a project-wide grep shows zero unsafe blocks, justifying uses-unsafe.

src/parser/mod.rs

src/parser/mod.rs, line 17-142

pub(crate) fn parse_document<'s>(
    source: toml_parser::Source<'s>,
    errors: &mut dyn prelude::ErrorSink,
) -> crate::Document<&'s str> {
    let tokens = source.lex().into_vec();

    let mut events = Vec::with_capacity(tokens.len());
    let mut receiver = ValidateWhitespace::new(&mut events, source);
    #[cfg(not(feature = "unbounded"))]
    let mut receiver = RecursionGuard::new(&mut receiver, LIMIT);
    #[cfg(not(feature = "unbounded"))]
    let receiver = &mut receiver;
    #[cfg(feature = "unbounded")]
    let receiver = &mut receiver;
    toml_parser::parser::parse_document(&tokens, receiver, errors);

    let mut input = prelude::Input::new(&events);
    let doc = document::document(&mut input, source, errors);
    doc
}

pub(crate) fn parse_key(
    source: toml_parser::Source<'_>,
    errors: &mut dyn prelude::ErrorSink,
) -> crate::Key {
    let tokens = source.lex().into_vec();

    let mut events = Vec::with_capacity(tokens.len());
    let mut receiver = ValidateWhitespace::new(&mut events, source);
    #[cfg(not(feature = "unbounded"))]
    let mut receiver = RecursionGuard::new(&mut receiver, LIMIT);
    #[cfg(not(feature = "unbounded"))]
    let receiver = &mut receiver;
    #[cfg(feature = "unbounded")]
    let receiver = &mut receiver;
    toml_parser::parser::parse_simple_key(&tokens, receiver, errors);

    if let Some(event) = events
        .iter()
        .find(|e| e.kind() == toml_parser::parser::EventKind::SimpleKey)
    {
        let (raw, key) = key::on_simple_key(event, source, errors);
        crate::Key::new(key).with_repr_unchecked(crate::Repr::new_unchecked(raw))
    } else {
        let key = source.input();
        let raw = RawString::with_span(0..source.input().len());
        crate::Key::new(key).with_repr_unchecked(crate::Repr::new_unchecked(raw))
    }
}

pub(crate) fn parse_key_path(
    source: toml_parser::Source<'_>,
    errors: &mut dyn prelude::ErrorSink,
) -> Vec<crate::Key> {
    let tokens = source.lex().into_vec();

    let mut events = Vec::with_capacity(tokens.len());
    let mut receiver = ValidateWhitespace::new(&mut events, source);
    #[cfg(not(feature = "unbounded"))]
    let mut receiver = RecursionGuard::new(&mut receiver, LIMIT);
    #[cfg(not(feature = "unbounded"))]
    let receiver = &mut receiver;
    #[cfg(feature = "unbounded")]
    let receiver = &mut receiver;
    toml_parser::parser::parse_key(&tokens, receiver, errors);

    let mut input = prelude::Input::new(&events);
    let mut prefix = None;
    let mut path = None;
    let mut key = None;
    let mut suffix = None;
    while let Some(event) = input.next_token() {
        match event.kind() {
            toml_parser::parser::EventKind::Whitespace => {
                let raw = RawString::with_span(event.span().start()..event.span().end());
                if prefix.is_none() {
                    prefix = Some(raw);
                } else if suffix.is_none() {
                    suffix = Some(raw);
                }
            }
            _ => {
                let (local_path, local_key) = key::on_key(event, &mut input, source, errors);
                path = Some(local_path);
                key = local_key;
            }
        }
    }
    if let Some(mut key) = key {
        if let Some(prefix) = prefix {
            key.leaf_decor.set_prefix(prefix);
        }
        if let Some(suffix) = suffix {
            key.leaf_decor.set_suffix(suffix);
        }
        let mut path = path.unwrap_or_default();
        path.push(key);
        path
    } else {
        Default::default()
    }
}

pub(crate) fn parse_value(
    source: toml_parser::Source<'_>,
    errors: &mut dyn prelude::ErrorSink,
) -> crate::Value {
    let tokens = source.lex().into_vec();

    let mut events = Vec::with_capacity(tokens.len());
    let mut receiver = ValidateWhitespace::new(&mut events, source);
    #[cfg(not(feature = "unbounded"))]
    let mut receiver = RecursionGuard::new(&mut receiver, LIMIT);
    #[cfg(not(feature = "unbounded"))]
    let receiver = &mut receiver;
    #[cfg(feature = "unbounded")]
    let receiver = &mut receiver;
    toml_parser::parser::parse_value(&tokens, receiver, errors);

    let mut input = prelude::Input::new(&events);
    let value = value::value(&mut input, source, errors);
    value
}

#[cfg(not(feature = "unbounded"))]
const LIMIT: u32 = 80;

Parser entry points (parse_document, parse_key, parse_key_path, parse_value) wrap the toml_parser lexer's event stream in ValidateWhitespace and (when unbounded is off) RecursionGuard with LIMIT = 80. The guard caps nesting depth across arrays, inline tables and dotted keys, preventing stack overflow from adversarial input. See FINDING-1 for the gap when the feature is enabled.

src/parser/value.rs

src/parser/value.rs, line 105-149

        }
        toml_parser::decoder::ScalarKind::Float => {
            let value = match decoded.parse::<f64>() {
                Ok(value) => {
                    if value.is_infinite()
                        && !(decoded
                            .strip_prefix(['+', '-'])
                            .unwrap_or(&decoded)
                            .chars()
                            .all(|c| c.is_ascii_alphabetic()))
                    {
                        errors.report_error(
                            ParseError::new("floating-point number overflowed")
                                .with_unexpected(event.span()),
                        );
                    }
                    value
                }
                Err(_) => {
                    errors.report_error(
                        ParseError::new(kind.invalid_description()).with_unexpected(event.span()),
                    );
                    f64::NAN
                }
            };
            let mut f = Formatted::new(value);
            f.set_repr_unchecked(Repr::new_unchecked(value_raw));
            Value::Float(f)
        }
        toml_parser::decoder::ScalarKind::Integer(radix) => {
            let value = match i64::from_str_radix(&decoded, radix.value()) {
                Ok(value) => value,
                Err(_) => {
                    // Assuming the decoder fully validated it, leaving only overflow errors
                    errors.report_error(
                        ParseError::new("integer number overflowed").with_unexpected(event.span()),
                    );
                    i64::MAX
                }
            };
            let mut f = Formatted::new(value);
            f.set_repr_unchecked(Repr::new_unchecked(value_raw));
            Value::Integer(f)
        }
    }

Scalar value decoding (float, integer): on overflow, an error is reported via the ErrorSink and a sentinel value (f64::NAN, i64::MAX) is emitted into the AST. The Document::parse wrapper in src/document.rs aborts with TomlError if the sink saw any error, so the sentinel value never reaches a successfully-parsed result. Justifies the "errors are surfaced" portion of parser-impl-safe.