Best Practices

Rust Edition Migration Security Notes

Field notes from migrating a production workspace from Rust 2018 to 2021, and what to watch for when 2024 lands in edition transitions.

Nayan Dey
Senior Security Engineer
7 min read

Rust editions are advertised as opt-in language updates with minimal breakage. That is mostly true. The part that is less discussed is that edition migrations have security implications, because changing the compiler's view of your code changes the set of bugs you might have, the set of patterns that are idiomatic, and the set of tooling that applies. I have shepherded two migrations now, 2015 to 2018 in 2020 and 2018 to 2021 in 2022-2023, and I am now prepping a workspace for the 2024 edition that stabilized in late 2024. Here are the things I wish someone had written down.

What An Edition Actually Changes

An edition is a per-crate opt-in for a set of language and standard library behavior changes that would otherwise be breaking. The compiler can handle a workspace where some crates are 2018 and others are 2021 because the edition affects parsing and name resolution per crate, not ABI. The 2021 edition added disjoint closure captures, new default prelude imports (TryFrom, TryInto, FromIterator), panic-on-overflow for arithmetic on signed and unsigned integers in .into() conversions in some cases, and IntoIterator for arrays.

The 2024 edition, which I have been testing against with rustc 1.83 nightly builds, includes changes to unsafe attribute syntax, new lifetime rules for impl Trait, gen blocks (unstable-gated), and a handful of migration lints. The security-relevant change I care about most is the unsafe_op_in_unsafe_fn default, which makes unsafe operations inside unsafe functions require an explicit unsafe block. That is genuinely a safety improvement.

The Migration Is Mostly Mechanical

cargo fix --edition does most of the heavy lifting. For the 2018 to 2021 migration of a thirty-crate workspace I ran last year, cargo fix handled roughly ninety percent of the rewrites. The remaining ten percent were cases where the closure capture semantics changed and the compiler could not automatically determine the intent.

The common pattern: a closure that previously captured a whole struct by reference now captures only the field it uses. In most code this is fine. In code that holds a lock guard for the lifetime of the closure, it can change when the guard is dropped. One specific bug I hit: a MutexGuard was held for the duration of a closure in 2018 because the closure captured the struct containing it; in 2021 the closure captured only the referenced field and the guard dropped earlier than expected. The race window this introduced was small but real. Caught in code review, not in production, but only because I was specifically looking for it.

The lesson from that migration: the mechanical changes are safe, but semantically-dependent patterns around locks, RAII, and Drop ordering need human review. Automated migration is not a substitute for reading the diff.

Security-Relevant Edition Differences

A few edition-level changes have direct security implications.

The 2018 edition's module system reform removed the extern crate boilerplate but also changed how paths resolve, which made some name shadowing issues clearer. A library that accidentally shadowed a stdlib function with its own was easier to spot after migration.

The 2021 edition's disjoint closure captures are a soundness improvement. The old semantics could capture more than intended, holding references longer than necessary and occasionally interacting badly with Drop. The new semantics capture minimally, which is what most code wanted.

The 2024 edition's unsafe_op_in_unsafe_fn default is a genuine hardening change. Under 2015/2018/2021 semantics, if you had an unsafe fn you could call any other unsafe thing inside it without marking the specific operation. That made unsafe functions much larger than they looked. Under 2024, you get a lint (expected to become an error) requiring an explicit unsafe { ... } block around the specific unsafe operation. This surfaces the actual unsafe surface area to reviewers.

Tooling That Does and Does Not Work Across Editions

Most supply chain tooling is edition-agnostic because it operates on the crate graph, not the source. cargo-audit, cargo-deny, and SBOM generators do not care what edition your crates are. So the migration itself does not change your advisory posture.

Lints and clippy checks can be edition-sensitive. Several clippy lints are warn-by-default only in later editions. If you migrate to 2021 and your CI runs clippy, you may get new lint failures that are not errors in the code but are errors in the lint policy. This is good; it means clippy is catching patterns the new edition considers non-idiomatic. Budget time for it in the migration.

rustc's own lints change across editions. The 2021 edition turned several lints from warn to deny-by-default. I have had migrations where the post-migration build produced thirty or forty new lint errors. They were all real issues; they had been warnings suppressed by terminal-scroll in the previous edition.

The Dependency-Graph Question

An edition migration does not force your dependencies to migrate. A 2018-edition dependency continues to work in a 2021-edition workspace. This is good for stability but has a subtle implication: the soundness improvements of later editions only apply to the crates that actually migrated.

For a security review, this means you should track which crates in your graph are on which edition. A load-bearing cryptography crate that is still on 2015 edition is not wrong, but it is missing several years of language-level hardening. Cargo.toml declares edition in a well-known field; parsing the dependency graph and reporting edition distribution is straightforward and worth doing periodically.

Planning A Migration

The recipe I use:

Start by pinning your toolchain via rust-toolchain.toml so the migration does not interact with a compiler update. Migrate in a branch. Run cargo fix --edition --workspace. Resolve any manual fixups. Run the full test suite, including any integration tests that touch concurrency or Drop-sensitive code. Run cargo clippy --all-targets and fix new lints even if they are warnings. Run your CI security gates (cargo-audit, cargo-deny) on the migrated branch. Deploy to a staging environment and let it cook for at least one release cycle before promoting.

Do not migrate multiple things at once. Pair an edition migration with a toolchain version bump or a major dependency update and you will not know which change broke things. I have seen this fail in practice; budget the migration as its own change.

The 2024 Edition Specifically

For the 2024 edition migration I am planning, the two things I am most attentive to are the unsafe_op_in_unsafe_fn default and the lifetime rule changes for impl Trait. The first will surface unsafe patterns for review, which is welcome but will be noisy initially. The second is more subtle and may cause lifetime-inference changes in unexpected places; I plan to migrate the workspace in a dedicated branch and run the soak period at least twice as long as I did for 2021.

How Safeguard Helps

Safeguard tracks the edition declared by every crate in your dependency graph and surfaces distribution over time, so you can spot load-bearing dependencies that are on old editions and plan migrations deliberately. When you open a migration PR we run the full supply chain gate against the edition-migrated build, not just the old build, so you catch edition-triggered lint regressions and feature-resolution differences before merge. We also treat unsafe-block surface-area changes as a first-class signal, which makes the 2024 edition's unsafe_op_in_unsafe_fn reviews tractable rather than overwhelming for the team doing the migration.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.