Skip to content

← Unsafe and Soundness step 3 of 24

Easy Primitives

`unsafe {}` vs `unsafe fn`: edition 2024 splits the two meanings

pub unsafe fn get_unchecked_sum(v: &[i64], idx: &[usize]) -> i64
pub fn checked_sum(v: Vec<i64>, idx: Vec<usize>) -> Option<i64>

get_unchecked_sum sums v[i] for every i in idx, using slice::get_unchecked — no bounds checks at all. It is unsafe to call, because an out-of-range index reads memory the slice does not own.

checked_sum is the safe wrapper: it validates, then calls. It returns None if idx is empty or if any index is >= v.len(), and Some(sum) otherwise. Use wrapping addition so a large input cannot overflow.

The starter compiles under rustc but fails the gate. Read the diagnostic carefully — it is E0133 and it is the whole lesson.

Two jobs that used to share one keyword

Before RFC 2585, unsafe fn did two completely unrelated things at once:

  1. it declared that the caller has obligations — “you must uphold X or this is UB”;
  2. it made the entire body an implicit unsafe block.

Job 2 was a disaster in practice. A 200-line unsafe fn had no marker anywhere saying which three of those lines were the dangerous ones. Every line was equally unmarked, so nothing stood out, so nothing got audited.

Edition 2024 splits them. unsafe fn now does only job 1. The body is safe Rust by default, and each unsafe operation inside it needs its own unsafe {} block, exactly as it would in any other function:

pub unsafe fn f(p: *const i32) -> i32 {
    // SAFETY: the caller guarantees `p` is valid for reads.
    unsafe { *p }
}

Mechanically, this is the lint unsafe_op_in_unsafe_fn. It is warn-by-default in edition 2024, so the code still builds — but because the grade here is clippy -D warnings, a warning is a failure. The diagnostic carries the code E0133 and the note “an unsafe function restricts its caller, but its body is safe by default”.

::: question If the body of an unsafe fn is now ordinary safe Rust, what does the unsafe on the function signature still mean? It means calling this function is an unsafe operation, so callers need their own unsafe {} block and their own justification.

It is a statement about the contract, not about the contents. The keyword now says one thing only: “there exist inputs, reachable from safe code, for which calling me is undefined behaviour — read my # Safety section and prove you are not passing them.” :::

The rule for when to mark a function unsafe

This is the single most useful heuristic in the topic, and it is not the one beginners guess.

Mark a function unsafe if and only if there exists an input, reachable from safe code, for which calling it is undefined behaviour.

Not “because it contains unsafe code”. slice::split_at_mut is full of unsafe code and is a perfectly safe function, because it checks mid <= len first and panics otherwise — every possible input either works or panics, and a panic is not UB.

That is the shape you are building here. get_unchecked_sum is unsafe because passing idx = [99] with a 3-element v is UB. checked_sum takes the same underlying operation and makes it safe by converting every bad input into None before the unsafe call happens. One function owns the danger; the other owns the proof.

Three lints are watching, and each teaches a different half

clippy::missing_safety_doc (on by default) will reject a pub unsafe fn with no # Safety section in its doc comment. Write a real one. It must say what the caller must guarantee — “every element of idx must be less than v.len()“ — not what the function does internally. A # Safety section is a contract offered to a stranger.

clippy::unnecessary_safety_doc is enabled by the template, and it fires in the other direction: a safe function with a # Safety section. Do not put one on checked_sum. There is nothing for its caller to uphold, and claiming otherwise trains readers to skim safety docs, which is worse than having none.

clippy::undocumented_unsafe_blocks, also enabled, wants a // SAFETY: comment on the line immediately before every unsafe {} — including the one inside checked_sum where you call get_unchecked_sum. That comment is where you discharge the obligation the # Safety section stated. If you find yourself unable to write it, that is the signal that the code is wrong.

::: question checked_sum calls an unsafe fn. Why is checked_sum itself not unsafe? Because no input to checked_sum can cause UB.

Feed it any Vec<i64> and any Vec<usize> you like — garbage indices, an empty list, indices larger than usize::MAX / 2. Every one of them either produces a sum or produces None. There is no reachable input for which the unsafe call happens with a bad index, because the guard runs first.

That is what “safe abstraction” means, and it is the entire business of this track: unsafe code with an enforced precondition wrapped in a safe signature. The standard library is thousands of instances of this pattern.

Note the corollary though — it is only true because the guard and the unsafe call are in the same function, so nothing can get between them. Item 17.20 is about what happens when they are merely in the same module, and why the answer is “you must audit the whole module”. :::

What the harness cannot check

Honesty, and this recurs on every page in this track: the grader cannot prove your code has no undefined behaviour. Miri needs a nightly toolchain and a cargo project and cannot run here.

If you write checked_sum with > instead of >= in the bounds test, every test case in this problem still passes — the off-by-one only reads one i64 past the end of the allocation, which on any real machine reads some adjacent heap byte and returns a number. It is undefined behaviour, the optimiser is entitled to assume it never happens, and nothing here will tell you. Read your guard twice.

Remember the grade is compile + tests + clippy -D warnings.