cargo / displaydoc / audit
cargo : displaydoc @ 0.2.6
PE Patrick Elsen signed 2026-06-02 published 2026-06-02

Claims

build-exec-deterministicbuild-exec-minimalbuild-exec-no-networkbuild-exec-no-write-outbuild-exec-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

displaydoc 0.2.6 is a small proc-macro crate that derives core::fmt::Display from /// doc comments. No build.rs, no binaries, no unsafe, no I/O; the only execution surface is the derive itself, a pure token-stream transformation over syn/quote/proc-macro2. Three findings: one medium correctness bug (#[doc(hidden)] panics the macro with "not implemented") and two low quality issues (panic!/expect in place of spanned syn errors; trybuild .stderr files excluded from the published crate).

Report

Subject

displaydoc is a Rust procedural-macro crate by Jane Lusby (jlusby@yaah.dev) that provides a #[derive(Display)] macro. The derive synthesises a core::fmt::Display implementation for the annotated type from the /// doc comments on its variants (for enums) or on the type itself (for structs), with shorthand interpolation: {var} and {0} are rewritten to write!-style positional arguments. The crate ships one library target with proc-macro = true (src/lib.rs) and depends only on syn, quote, and proc-macro2. The std default feature enables a dtolnay-style autoref-specialization shim that lets {path} work with std::path::Path and std::path::PathBuf by calling self.display(); under --no-default-features the crate compiles for no_std consumers.

Methodology

Tooling used:

  • openvet audit new (0.x) to fetch and unpack the crate from crates.io and clone the GitHub repository at the commit recorded in .cargo_vcs_info.json.
  • diff -r (Apple Darwin) to compare published crate contents against the upstream VCS checkout.
  • grep / ripgrep-style searches across contents/src and contents/tests for unsafe, extern "C", std::process::, std::net::, std::fs::, env::, panic-prone calls (panic!, expect, unwrap, unimplemented!, unreachable!), and HTTP-client crates.
  • Manual line-by-line read of all four files under src/ (~900 lines total) and all five integration test files under tests/.
  • cargo build (1.x stable) on a small reproducer crate (tmp/repro/) to confirm one finding.

The published displaydoc-0.2.6.crate was diffed against the upstream repository at commit 50d6f1f (the commit pinned in .cargo_vcs_info.json). Source files (src/**/*.rs, tests/**/*.rs) match byte-for-byte; differences are limited to cargo's Cargo.toml normalisation and to files excluded by the include list (.github/, examples/, update-readme.sh, tests/ui/*.stderr, README.tpl).

The crate's runtime surface is the proc macro itself, which runs at compile time on every downstream consumer of #[derive(Display)]. The macro was inspected end to end: token-stream construction in src/expand.rs, the format-shorthand parser in src/fmt.rs, and the attribute extraction in src/attr.rs. The unit tests in src/fmt.rs::tests cover the format-shorthand expansion for both std and no_std configurations; the integration tests cover happy-path enum/struct expansion, no_std compilation, and a trybuild UI suite gated to nightly.

Results

The diff between published contents and VCS shows no unexpected changes. The crate contains no binary artefacts (justifying has-binaries) and no build.rs; the only build-time code is the proc-macro entry point itself (pub fn derive_error in src/lib.rs), which produces tokens by walking a syn::DeriveInput and emits no std::process, std::net, std::fs, or std::env calls — justifying build-exec-safe, build-exec-no-network, build-exec-no-write-out, build-exec-deterministic, and build-exec-minimal. The proc-macro = true declaration alone justifies has-build-exec.

The codebase was reviewed for unsafe, FFI, process spawning, network or filesystem I/O, environment variables, concurrency primitives, and cryptography. None was found, justifying uses-unsafe, uses-exec, uses-network, uses-filesystem, uses-environment, uses-concurrency, uses-crypto, uses-jit, and uses-interpreter, and likewise the corresponding implementation claims impl-crypto, impl-interpreter, impl-jit, impl-protocol, impl-datastructure, impl-algorithm, and impl-concurrency. The format-shorthand transformation in src/fmt.rs is a small text rewrite (~80 LOC), not parsing a data format into a structure, justifying impl-parser.

Three findings were recorded. FINDING-1 (medium, correctness) documents a real bug: any #[doc(...)] attribute with a non-NameValue shape on a Display-deriving type causes a proc-macro panic. The filter at src/attr.rs:72-85 selects every #[doc...] attribute but the subsequent match falls through to unimplemented!() for anything other than Meta::NameValue. Confirmed with a reproducer (tmp/repro/) using #[doc(hidden)]; output: error: proc-macro derive panicked ... = help: message: not implemented. The bug does not affect generated code — no crate compiles successfully — so it is a diagnostic/ergonomic issue, not a soundness hazard.

FINDING-2 (low, quality) notes two panic!/expect sites in src/attr.rs (lines 62, 110, 130) that should be syn::Error::new_spanned; the entry point at src/lib.rs:184 already wraps the result in to_compile_error(), so a structured error would yield a properly spanned diagnostic. FINDING-3 (low, quality) notes that the .stderr reference files referenced by tests/compile_tests.rs are excluded from the published crate via the include list, so the trybuild harness cannot validate expected error text downstream. The harness is gated to nightly via #[rustversion::attr(not(nightly), ignore)].

No malicious behaviour was identified, justifying is-benign.

Conclusion

displaydoc is a small (~900 LOC), focused procedural macro with a clear public API and a well-scoped dependency set (syn, quote, proc-macro2). The crate ships no build.rs, no binaries, no unsafe code, no I/O of any kind, and no network or filesystem touches; its only execution surface is the derive macro itself, which is a pure token-stream transformation. Tests cover the main code paths but include no fuzz or property tests for the format-shorthand parser. The one substantive finding (FINDING-1) is a panic on #[doc(hidden)] that produces a poor error message but cannot affect generated code; the other two findings are minor diagnostic and packaging quality issues.

Findings(3)

FINDING-1 correctness medium

Doc attribute MetaList causes proc-macro panic

Any #[doc(...)] attribute (e.g., #[doc(hidden)]) on a type that derives Display causes a proc-macro panic with the message not implemented, rather than being ignored or producing a structured compile error.

In src/attr.rs:72-85, the filter attr.path().is_ident("doc") selects every attribute named doc, including MetaList forms such as #[doc(hidden)]. The subsequent match expects Meta::NameValue with a string literal and falls through to unimplemented!() for any other shape:

.filter(|attr| attr.path().is_ident("doc"))
.map(|attr| match &attr.meta {
    Meta::NameValue(syn::MetaNameValue { value: ... lit: syn::Lit::Str(lit) ... }) => lit,
    _ => unimplemented!(),
});

Confirmed with a reproducer (tmp/repro/):

#[derive(Display)]
#[doc(hidden)]
/// a doc string
pub struct Foo;

produces:

error: proc-macro derive panicked
 --> src/lib.rs:3:10
  |
3 | #[derive(Display)]
  |          ^^^^^^^
  |
  = help: message: not implemented

#[doc(hidden)] is a common idiom for hiding items from rustdoc, and using it together with #[derive(Display)] is a reasonable thing for a downstream user to do. The fix is to skip non-MetaNameValue doc attributes in the filter rather than panicking. The bug does not affect generated code (no crate compiles successfully) and so does not represent a soundness or safety hazard.

FINDING-2 quality low

Error paths use panic/expect instead of syn::Error::new_spanned

Three user-facing diagnostics in src/attr.rs are emitted via expect or panic! rather than syn::Error::new_spanned:

  • attr.rs:62 - .expect("#[displaydoc(\"foo\")] must contain string arguments") when #[displaydoc(...)] arguments fail to parse.
  • attr.rs:110 - panic! when a multi-line doc comment contains a paragraph break and #[ignore_extra_doc_attributes] is not set.
  • attr.rs:130 - .expect("Missing doc comment on enum with #[prefix_enum_doc_attributes]...") when the marker attribute is used without a doc comment on the enum.

derive is wrapped in unwrap_or_else(|err| err.to_compile_error()) at lib.rs:184, so syn::Error would produce a proper compile error pointing at the offending span. panic!/expect instead surface as proc-macro derive panicked with no span, hindering diagnosis. The behavior is reachable through normal misuse but does not affect generated code.

FINDING-3 quality low

tests/ui .stderr files omitted from published crate

The compile_tests integration test (tests/compile_tests.rs) invokes trybuild against eight files under tests/ui/, four of which (without.rs, multi_line_line_break.rs, multi_line_line_break_block.rs, enum_prefix_missing.rs) use compile_fail. The corresponding .stderr reference outputs exist in the upstream VCS tree but are excluded from the published crate because the include list in Cargo.toml.orig only globs src/**/*.rs and tests/**/*.rs. A downstream consumer running cargo test on nightly therefore cannot verify the expected error messages; trybuild will emit a wip note rather than a pass. The compile_tests harness is gated to nightly with #[rustversion::attr(not(nightly), ignore)], limiting the impact to nightly users running the test suite of the published crate.

Annotations(7)

Cargo.toml

Cargo.toml, line 100-103

[lib]
name = "displaydoc"
path = "src/lib.rs"
proc-macro = true

proc-macro = true triggers has-build-exec: the crate's code runs at compile time on every downstream consumer that derives Display.

Cargo.toml, line 18-18

build = false

build = false is cargo's normalized indicator that no build script is shipped; combined with the absence of any build.rs in the crate root, this justifies has-install-exec.

src/attr.rs

src/attr.rs, line 72-85

        let literals = attrs
            .iter()
            .filter(|attr| attr.path().is_ident("doc"))
            .map(|attr| match &attr.meta {
                Meta::NameValue(syn::MetaNameValue {
                    value:
                        syn::Expr::Lit(syn::ExprLit {
                            lit: syn::Lit::Str(lit),
                            ..
                        }),
                    ..
                }) => lit,
                _ => unimplemented!(),
            });

Filter selects every #[doc...] attribute (including MetaList like #[doc(hidden)]) but the match only handles MetaNameValue; the fall-through hits unimplemented!() at line 84. See FINDING-1.

src/attr.rs, line 109-111

        }.unwrap_or_else(|| {
            panic!("Paragraph breaks in multi-line doc comments are disabled by default by displaydoc. Please consider using block doc comments (/** */) or adding the #[ignore_extra_doc_attributes] attribute to your type next to the derive");
        }).join(" ");

User-facing diagnostic via panic! instead of syn::Error::new_spanned. See FINDING-2.

src/expand.rs

src/expand.rs, line 31-71

#[cfg(feature = "std")]
fn specialization() -> TokenStream {
    quote! {
        trait DisplayToDisplayDoc {
            fn __displaydoc_display(&self) -> Self;
        }

        impl<T: ::core::fmt::Display> DisplayToDisplayDoc for &T {
            fn __displaydoc_display(&self) -> Self {
                self
            }
        }

        // If the `std` feature gets enabled we want to ensure that any crate
        // using displaydoc can still reference the std crate, which is already
        // being compiled in by whoever enabled the `std` feature in
        // `displaydoc`, even if the crates using displaydoc are no_std.
        extern crate std;

        trait PathToDisplayDoc {
            fn __displaydoc_display(&self) -> std::path::Display<'_>;
        }

        impl PathToDisplayDoc for std::path::Path {
            fn __displaydoc_display(&self) -> std::path::Display<'_> {
                self.display()
            }
        }

        impl PathToDisplayDoc for std::path::PathBuf {
            fn __displaydoc_display(&self) -> std::path::Display<'_> {
                self.display()
            }
        }
    }
}

#[cfg(not(feature = "std"))]
fn specialization() -> TokenStream {
    quote! {}
}

Autoref-specialization helpers emitted with the std feature. DisplayToDisplayDoc is implemented for &T: Display; PathToDisplayDoc is implemented for std::path::Path and PathBuf returning std::path::Display<'_> from self.display(). This lets {path} interpolate via Path::display() rather than the missing Display impl on Path. The trick is the dtolnay autoref-specialization pattern referenced in the README FAQ. Under no_std, no helpers are emitted.

src/expand.rs, line 281-352

/// Hygienically add `where _: Display` to the set of [TypeParamBound]s for `ident`, creating such
/// a set if necessary.
fn ensure_display_in_where_clause_for_type(where_clause: &mut WhereClause, ident: Ident) {
    for pred_ty in where_clause
        .predicates
        .iter_mut()
        // Find the `where` predicate constraining the current type param, if it exists.
        .flat_map(|predicate| match predicate {
            WherePredicate::Type(pred_ty) => Some(pred_ty),
            // We're looking through type constraints, not lifetime constraints.
            _ => None,
        })
    {
        // Do a complicated destructuring in order to check if the type being constrained in this
        // `where` clause is the type we're looking for, so we can use the mutable reference to
        // `pred_ty` if so.
        let matches_desired_type = matches!(
            &pred_ty.bounded_ty,
            Type::Path(TypePath { path, .. }) if Some(&ident) == path.get_ident());
        if matches_desired_type {
            add_display_constraint_to_type_predicate(pred_ty);
            return;
        }
    }

    // If there is no `where` predicate for the current type param, we will construct one.
    let mut new_type_predicate = new_empty_where_type_predicate(ident);
    add_display_constraint_to_type_predicate(&mut new_type_predicate);
    append_where_clause_type_predicate(where_clause, new_type_predicate);
}

/// For all declared type parameters, add a [core::fmt::Display] constraint, unless the type
/// parameter already has any type constraint.
fn ensure_where_clause_has_display_for_all_unconstrained_members(
    where_clause: &mut WhereClause,
    type_params: &[&TypeParam],
) {
    let param_constraint_mapping = extract_trait_constraints_from_source(where_clause, type_params);

    for (ident, known_bounds) in param_constraint_mapping.into_iter() {
        // If the type parameter has any constraints already, we don't want to touch it, to avoid
        // breaking use cases where a type parameter only needs to impl `Debug`, for example.
        if known_bounds.is_empty() {
            ensure_display_in_where_clause_for_type(where_clause, ident);
        }
    }
}

/// Generate a `where` clause that ensures all generic type parameters `impl`
/// [core::fmt::Display] unless already constrained.
///
/// This approach allows struct/enum definitions deriving [crate::Display] to avoid hardcoding
/// a [core::fmt::Display] constraint into every type parameter.
///
/// If the type parameter isn't already constrained, we add a `where _: Display` clause to our
/// display implementation to expect to be able to format every enum case or struct member.
///
/// In fact, we would preferably only require `where _: Display` or `where _: Debug` where the
/// format string actually requires it. However, while [`std::fmt` defines a formal syntax for
/// `format!()`][format syntax], it *doesn't* expose the actual logic to parse the format string,
/// which appears to live in [`rustc_parse_format`]. While we use the [`syn`] crate to parse rust
/// syntax, it also doesn't currently provide any method to introspect a `format!()` string. It
/// would be nice to contribute this upstream in [`syn`].
///
/// [format syntax]: std::fmt#syntax
/// [`rustc_parse_format`]: https://doc.rust-lang.org/nightly/nightly-rustc/rustc_parse_format/index.html
fn generate_where_clause(generics: &Generics, where_clause: Option<&WhereClause>) -> WhereClause {
    let mut where_clause = where_clause.cloned().unwrap_or_else(new_empty_where_clause);
    let type_params: Vec<&TypeParam> = generics.type_params().collect();
    ensure_where_clause_has_display_for_all_unconstrained_members(&mut where_clause, &type_params);
    where_clause
}

Where-clause synthesis: for every generic type parameter without an existing bound, adds T: ::core::fmt::Display. Parameters that already have any trait bound (e.g., T: Debug) are left untouched, matching the documented "Using Debug Implementations with Type Parameters" guidance in lib.rs.

src/fmt.rs

src/fmt.rs, line 17-59

    pub(crate) fn expand_shorthand(&mut self) {
        let span = self.fmt.span();
        let fmt = self.fmt.value();
        let mut read = fmt.as_str();
        let mut out = String::new();
        let mut args = TokenStream::new();

        while let Some(brace) = read.find('{') {
            out += &read[..=brace];
            read = &read[brace + 1..];

            // skip cases where we find a {{
            if read.starts_with('{') {
                out.push('{');
                read = &read[1..];
                continue;
            }

            let next = peek_next!(read);

            let var = match next {
                '0'..='9' => take_int(&mut read),
                'a'..='z' | 'A'..='Z' | '_' => take_ident(&mut read),
                _ => return,
            };

            let ident = Ident::new(&var, span);

            let next = peek_next!(read);

            let arg = if cfg!(feature = "std") && next == '}' {
                quote_spanned!(span=> , #ident.__displaydoc_display())
            } else {
                quote_spanned!(span=> , #ident)
            };

            args.extend(arg);
        }

        out += read;
        self.fmt = LitStr::new(&out, self.fmt.span());
        self.args = args;
    }

Format string shorthand expansion: transforms "error {var}" into "error {}", var and "error {0}" into "error {}", _0. Iterates over {...} openings, peeking the next character to dispatch to take_int or take_ident. All byte-slice operations use indices from char_indices(), keeping slicing at valid UTF-8 boundaries. No unsafe, no panics on valid input.

src/lib.rs

src/lib.rs, line 177-186

#[proc_macro_derive(
    Display,
    attributes(ignore_extra_doc_attributes, prefix_enum_doc_attributes, displaydoc)
)]
pub fn derive_error(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    expand::derive(&input)
        .unwrap_or_else(|err| err.to_compile_error())
        .into()
}

Proc-macro entry point. #[proc_macro_derive(Display, attributes(...))] declares the derive and registers the three helper attributes (ignore_extra_doc_attributes, prefix_enum_doc_attributes, displaydoc). Error returned from expand::derive is converted via to_compile_error(), justifying the rationale in FINDING-2.

tests

Five integration test files plus the unit-test module in src/fmt.rs cover the happy path, struct/enum/unit/tuple forms, the #[ignore_extra_doc_attributes] and #[displaydoc(...)] attributes, no_std builds (tests/no_std.rs), digit-prefixed field names (tests/num_in_field.rs) and the empty-enum case (tests/variantless.rs). Justifies has-unit-tests and has-integration-tests. No fuzz harness and no property-test harness ship with the crate or appear in the VCS tree, justifying has-fuzz-tests and has-property-tests.

tests/compile_tests.rs

trybuild harness that exercises eight files in tests/ui/, four of them with compile_fail. The reference .stderr files are absent from the published crate. See FINDING-3.