Skip to main content

rapx/verify/property_checker/
cstr.rs

1//! ValidCStr property checking for the symbolic VM.
2
3use rustc_middle::mir::{Body, Local, Operand, Rvalue, StatementKind, TerminatorKind};
4use z3::{
5    Solver,
6    ast::{Ast, Int},
7};
8
9use crate::compat::FxHashMap;
10use crate::helpers::mir_scan::Checkpoint;
11use crate::helpers::mir_utils::dep_callee_def_id;
12use crate::verify::api_classify::is_as_ptr;
13use crate::verify::vm::state::{AllocId, VmState};
14use crate::verify::{
15    contract::{ContractExpr, Property, PropertyArg},
16    report::{CheckResult, UnknownReason},
17};
18
19use super::PropertyChecker;
20
21/// The root local of the checkpoint's first argument, when it is a plain
22/// `Copy`/`Move` operand with no projection.
23fn first_arg_local<'tcx>(checkpoint: &Checkpoint<'tcx>) -> Option<Local> {
24    checkpoint.args.get(0).and_then(|op| match op {
25        Operand::Copy(p) | Operand::Move(p) if p.projection.is_empty() => Some(p.local),
26        _ => None,
27    })
28}
29
30/// Build a `local -> source` map for `Use`/`Cast`/`Ref`/`RawPtr`/`CopyForDeref`
31/// assignments and `as_ptr` calls.
32fn body_parents(body: &Body<'_>) -> FxHashMap<Local, Local> {
33    let mut parents: FxHashMap<Local, Local> = Default::default();
34    for data in body.basic_blocks.iter() {
35        for statement in &data.statements {
36            let StatementKind::Assign(assign) = &statement.kind else {
37                continue;
38            };
39            let (target, rvalue) = assign.as_ref();
40            let source = match rvalue {
41                Rvalue::Use(Operand::Copy(place) | Operand::Move(place), ..)
42                | Rvalue::Cast(_, Operand::Copy(place) | Operand::Move(place), _)
43                | Rvalue::Ref(_, _, place)
44                | Rvalue::RawPtr(_, place)
45                | Rvalue::CopyForDeref(place) => Some(place.local),
46                _ => None,
47            };
48            if let Some(source) = source {
49                parents.entry(target.local).or_insert(source);
50            }
51        }
52        let Some(terminator) = &data.terminator else {
53            continue;
54        };
55        let TerminatorKind::Call {
56            func,
57            args,
58            destination,
59            ..
60        } = &terminator.kind
61        else {
62            continue;
63        };
64        if !is_as_ptr(dep_callee_def_id(func)) {
65            continue;
66        }
67        let Some(source) = args.first().and_then(|arg| match &arg.node {
68            Operand::Copy(place) | Operand::Move(place) => Some(place.local),
69            _ => None,
70        }) else {
71            continue;
72        };
73        parents.entry(destination.local).or_insert(source);
74    }
75    parents
76}
77
78impl PropertyChecker {
79    // ── check_valid_cstr ───────────────────────────────────────
80
81    pub(super) fn check_valid_cstr<'z3, 'tcx>(
82        &self,
83        vm_state: &VmState<'z3, 'tcx>,
84        solver: &Solver<'z3>,
85        checkpoint: &Checkpoint<'tcx>,
86        property: &Property<'tcx>,
87    ) -> CheckResult {
88        let value = self
89            .target_value(vm_state, checkpoint, property)
90            .or_else(|| {
91                checkpoint
92                    .destination
93                    .and_then(|d| vm_state.local_value(d).cloned())
94            });
95        let Some(value) = value else {
96            return CheckResult::Unknown(UnknownReason::Unimplemented);
97        };
98
99        // If we have provenace, check liveness and byte-level tracking
100        if let Some(alloc_id) = value.provenance_alloc_id() {
101            // A `Vec`/boxed buffer exposes its byte data through the container's
102            // owning pointer field (or a slice view's parent chain); track the
103            // heap buffer rather than the container struct's stack allocation.
104            let alloc_id = vm_state.data_alloc_of(alloc_id, value.ty).unwrap_or(alloc_id);
105            if vm_state.alloc(alloc_id).facts.dead {
106                return CheckResult::Failed;
107            }
108
109            // The allocation was asserted to be a null-terminated C string via
110            // a `ValidCStr` contract fact / struct invariant. Any sub-slice of
111            // it is therefore nul-terminated (it ends at the same nul byte),
112            // so the property holds without further byte-level reasoning.
113            // Follow `parent` so a `from_raw_parts` / slice-index
114            // sub-allocation rooted in a nul-terminated buffer also passes.
115            let mut root_id = alloc_id;
116            while let Some(parent_id) = vm_state.alloc(root_id).parent {
117                root_id = parent_id;
118            }
119            if vm_state.is_cstr_trusted(alloc_id) || vm_state.is_cstr_trusted(root_id) {
120                return CheckResult::ProvedByRule;
121            }
122
123            let alloc_size = vm_state.allocation_size(alloc_id).clone();
124
125            // The `ValidCStr(p, n)` length argument is the exact byte length of
126            // the nul-terminated buffer.  Prefer it over the allocation size
127            // (which may be larger, e.g. a `Vec` with spare capacity).  A
128            // `Const` length is the `1` placeholder used for raw pointers
129            // (`from_ptr`), whose true length is `strlen(ptr) + 1` and is not
130            // expressible in the contract, so it is ignored.
131            let n_term = property.args().get(1).and_then(|a| match a {
132                PropertyArg::Expr(ContractExpr::Const(_)) => None,
133                a => self.resolve_arg_term(vm_state, checkpoint, a),
134            });
135            let buffer_size = n_term
136                .filter(|t| t.as_u64().is_some())
137                .or(Some(alloc_size));
138
139            // Starting offset within the allocation (for pointer arithmetic like .add(2))
140            let start_offset = value
141                .provenance
142                .as_ref()
143                .and_then(|p| p.offset.as_u64())
144                .map(|v| v as usize)
145                .unwrap_or(0);
146
147            // 1. Try fast-path: concrete byte-level check from known_nul / known_non_nul
148            if let Some(r) = self.check_valid_cstr_from_known_nul(vm_state, alloc_id, start_offset)
149            {
150                return r;
151            }
152
153            // 2. Try byte_value-based symbolic check via SMT
154            if let Some(size) = buffer_size {
155                if let Some(r) =
156                    self.check_valid_cstr_from_byte_values(vm_state, solver, alloc_id, &size)
157                {
158                    return r;
159                }
160            }
161        }
162
163        // 3. MIR-level fallback: scan the body for constant byte assignments
164        //    (mirrors the legacy checker's approach for promoted constants)
165        if let Some(r) = self.check_valid_cstr_from_mir_constants(vm_state, checkpoint) {
166            return r;
167        }
168
169        // 4. Fallback: if the constructor requires strict NUL-termination
170        //    (from_bytes_with_nul_unchecked, from_vec_with_nul_unchecked)
171        //    and we can't verify all bytes, return Unknown.
172        let is_strict =
173            crate::verify::api_classify::is_cstr_unchecked_constructor(checkpoint.callee);
174        if is_strict {
175            CheckResult::Unknown(UnknownReason::Unimplemented)
176        } else {
177            CheckResult::ProvedByRule
178        }
179    }
180
181    /// Fast-path: check NUL termination using per-byte NUL/non-NUL knowledge.
182    /// This handles constant byte strings like `b"hello\0"` and aggregate initializers
183    /// where all element operands are constants.
184    /// `start_offset` is the byte offset within the allocation where the C string begins
185    /// (non-zero when pointer arithmetic like `.add(n)` is used).
186    fn check_valid_cstr_from_known_nul<'z3, 'tcx>(
187        &self,
188        vm_state: &VmState<'z3, 'tcx>,
189        alloc_id: AllocId,
190        start_offset: usize,
191    ) -> Option<CheckResult> {
192        // Collect all concrete offsets where we know what the byte is
193        let nul_offsets_all = vm_state.alloc_nul_offsets(alloc_id);
194        let known_offsets: Vec<usize> = nul_offsets_all
195            .iter()
196            .copied()
197            .chain(vm_state.alloc_non_nul_offsets(alloc_id))
198            .collect();
199
200        if known_offsets.is_empty() {
201            return None; // no byte-level info
202        }
203
204        let max_known = known_offsets.iter().max().copied().unwrap_or(0);
205
206        // Find the NUL byte at or after start_offset
207        let nul_offsets: Vec<usize> = nul_offsets_all
208            .into_iter()
209            .filter(|off| *off >= start_offset)
210            .collect();
211
212        if nul_offsets.is_empty() {
213            // No NUL in tracked range — might be in untracked region.
214            let size = vm_state.allocation_size(alloc_id);
215            if let Some(size_val) = size.as_u64() {
216                if max_known + 1 < size_val as usize {
217                    return None;
218                }
219            }
220            return Some(CheckResult::Failed);
221        }
222
223        // Check if there's exactly one NUL at the end of the known range
224        let min_nul = nul_offsets.iter().min().copied().unwrap_or(0);
225
226        // All offsets between start_offset and min_nul must be known non-NUL
227        for off in start_offset..min_nul {
228            if vm_state.is_byte_nul(alloc_id, off) {
229                // Interior NUL found before the first NUL after start_offset
230                return Some(CheckResult::Failed);
231            }
232            if !vm_state.is_byte_non_nul(alloc_id, off) {
233                // Unknown byte — can't prove valid
234                return None;
235            }
236        }
237
238        // If multiple NUL offsets exist and the first NUL is not at the last
239        // tracked position, there is an interior NUL → invalid C string.
240        if nul_offsets.len() > 1 && min_nul < max_known {
241            return Some(CheckResult::Failed);
242        }
243
244        // All bytes between start_offset and the first NUL are known non-NUL,
245        // and the NUL itself is known. This is a valid C string for the tracked range.
246        Some(CheckResult::ProvedByRule)
247    }
248
249    /// Check NUL termination using per-byte symbolic values tracked in `bytes`.
250    /// Uses the SMT solver to verify that a NUL-terminated byte sequence is possible.
251    fn check_valid_cstr_from_byte_values<'z3, 'tcx>(
252        &self,
253        vm_state: &VmState<'z3, 'tcx>,
254        solver: &Solver<'z3>,
255        alloc_id: AllocId,
256        alloc_size: &Int<'z3>,
257    ) -> Option<CheckResult> {
258        let byte_pairs = vm_state.alloc_byte_values(alloc_id);
259        if byte_pairs.is_empty() {
260            return None;
261        }
262
263        let zero = Int::from_u64(vm_state.z3_ctx, 0);
264        let size_u64 = alloc_size.as_u64();
265
266        if size_u64.is_none() {
267            // Symbolic size: a C string has exactly one NUL (at the end), so two
268            // distinct tracked bytes that can *both* be NUL is a confirmed
269            // interior-NUL violation (a counterexample where two NULs coexist).
270            if byte_pairs.len() >= 2 {
271                solver.push();
272                solver.assert(&byte_pairs[0].1._eq(&zero));
273                solver.assert(&byte_pairs[1].1._eq(&zero));
274                let r = solver.check();
275                solver.pop(1);
276                if r == z3::SatResult::Sat {
277                    return Some(CheckResult::Failed);
278                }
279            }
280            return None; // symbolic-size allocations need different handling
281        }
282
283        for (nul_off, nul_term) in &byte_pairs {
284            // A valid C string's NUL is the *last* byte; an interior NUL at an
285            // earlier offset is a violation, not a candidate terminator.
286            if let Some(size) = size_u64 {
287                if *nul_off + 1 != size as usize {
288                    continue;
289                }
290            }
291            solver.push();
292            solver.assert(&nul_term._eq(&zero));
293
294            for (off, term) in &byte_pairs {
295                if *off < *nul_off {
296                    solver.assert(&term._eq(&zero).not());
297                }
298            }
299
300            let r = solver.check();
301            solver.pop(1);
302
303            if r == z3::SatResult::Sat {
304                let mut interior_safe = true;
305                for (off, term) in &byte_pairs {
306                    if *off < *nul_off {
307                        solver.push();
308                        solver.assert(&term._eq(&zero));
309                        let inner = solver.check();
310                        solver.pop(1);
311                        if inner != z3::SatResult::Unsat {
312                            interior_safe = false;
313                            break;
314                        }
315                    }
316                }
317                if interior_safe {
318                    return Some(CheckResult::ProvedByRule);
319                }
320            }
321        }
322
323        // If no valid NUL position found, check if the last byte is tracked
324        // and no NUL exists among tracked bytes
325        let has_nul_in_tracked = byte_pairs.iter().any(|(_, term)| {
326            solver.push();
327            solver.assert(&term._eq(&zero));
328            let r = solver.check();
329            solver.pop(1);
330            r == z3::SatResult::Sat
331        });
332
333        if !has_nul_in_tracked {
334            let last_off = byte_pairs.last().map(|(off, _)| *off).unwrap_or(0);
335            if let Some(size) = size_u64 {
336                if last_off + 1 >= size as usize {
337                    return Some(CheckResult::Failed);
338                }
339            }
340        }
341
342        // The final byte is definitely NUL, but some *interior* byte may also be
343        // NUL — a confirmed interior-NUL violation (a counterexample where that
344        // byte is 0), not an incomplete proof.
345        if let Some(size) = size_u64 {
346            if let Some((last_off, last_term)) = byte_pairs.last() {
347                if *last_off + 1 == size as usize {
348                    solver.push();
349                    solver.assert(&last_term._eq(&zero).not());
350                    let last_not_nul = solver.check();
351                    solver.pop(1);
352                    if last_not_nul == z3::SatResult::Unsat {
353                        for (off, term) in &byte_pairs {
354                            if *off < *last_off {
355                                solver.push();
356                                solver.assert(&term._eq(&zero));
357                                let r = solver.check();
358                                solver.pop(1);
359                                if r == z3::SatResult::Sat {
360                                    return Some(CheckResult::Failed);
361                                }
362                            }
363                        }
364                    }
365                }
366            }
367        }
368
369        None
370    }
371
372    /// Scan MIR blocks for a single `0_u8` store into the target buffer.
373    /// When exactly one nul-store exists among all constant stores, we
374    /// can prove ValidCStr even without VM-level byte tracking.  This
375    /// mirrors the legacy `nul_store_before_checkpoint` logic.
376    fn check_valid_cstr_nul_store<'tcx>(
377        vm_state: &VmState<'_, 'tcx>,
378        checkpoint: &Checkpoint<'tcx>,
379    ) -> Option<CheckResult> {
380        let target_local = first_arg_local(checkpoint)?;
381        let body = vm_state.body();
382
383        // Build parent map (same as legacy)
384        let parents = body_parents(body);
385        let root = crate::helpers::mir_utils::resolve_through_casts(
386            body,
387            crate::helpers::mir_utils::follow_parents(&parents, target_local),
388        );
389
390        let mut buffer_locals: rustc_hash::FxHashSet<Local> = rustc_hash::FxHashSet::default();
391        let mut seen = rustc_hash::FxHashSet::default();
392        let mut work = vec![root];
393        while let Some(local) = work.pop() {
394            if !seen.insert(local) {
395                continue;
396            }
397            for data in body.basic_blocks.iter() {
398                for stmt in &data.statements {
399                    let StatementKind::Assign(assign) = &stmt.kind else {
400                        continue;
401                    };
402                    let (target, rvalue) = &**assign;
403                    if target.local != local || !target.projection.is_empty() {
404                        continue;
405                    }
406                    if let Rvalue::Ref(_, _, place) = rvalue {
407                        buffer_locals.insert(place.local);
408                    }
409                    #[cfg(rapx_rvalue_use_with_retag)]
410                    if let Rvalue::Use(Operand::Copy(p) | Operand::Move(p), _) = rvalue {
411                        work.push(p.local);
412                    }
413                    #[cfg(not(rapx_rvalue_use_with_retag))]
414                    if let Rvalue::Use(Operand::Copy(p) | Operand::Move(p)) = rvalue {
415                        work.push(p.local);
416                    }
417                    if let Rvalue::Cast(_, Operand::Copy(p) | Operand::Move(p), _) = rvalue {
418                        if p.projection.is_empty() {
419                            work.push(p.local);
420                        }
421                    }
422                }
423            }
424        }
425
426        let mut nul_store_count = 0u32;
427        for data in body.basic_blocks.iter() {
428            for stmt in &data.statements {
429                let StatementKind::Assign(assign) = &stmt.kind else {
430                    continue;
431                };
432                let (target, rvalue) = &**assign;
433                let target_root = crate::helpers::mir_utils::follow_parents(&parents, target.local);
434                if target_root != root && !buffer_locals.contains(&target_root) {
435                    continue;
436                }
437                if target.projection.is_empty() {
438                    continue;
439                }
440                #[cfg(rapx_rvalue_use_with_retag)]
441                let Rvalue::Use(Operand::Constant(c), _) = rvalue else {
442                    continue;
443                };
444                #[cfg(not(rapx_rvalue_use_with_retag))]
445                let Rvalue::Use(Operand::Constant(c)) = rvalue else {
446                    continue;
447                };
448                if c.const_
449                    .try_to_scalar_int()
450                    .is_some_and(|s| s.to_uint(s.size()) == 0)
451                {
452                    nul_store_count += 1;
453                }
454            }
455        }
456
457        if nul_store_count == 1 {
458            Some(CheckResult::ProvedByRule)
459        } else if nul_store_count > 1 {
460            Some(CheckResult::Failed)
461        } else {
462            None
463        }
464    }
465
466    /// Fallback: scan the MIR body for constant byte assignments to the target
467    /// pointer's root local. Uses worklist-based analysis (handles as_ptr chains
468    /// and branches), falling back to simple local chain for Aggregate cases.
469    fn check_valid_cstr_from_mir_constants<'z3, 'tcx>(
470        &self,
471        vm_state: &VmState<'z3, 'tcx>,
472        checkpoint: &Checkpoint<'tcx>,
473    ) -> Option<CheckResult> {
474        let target_local = first_arg_local(checkpoint)?;
475
476        let body = vm_state.body();
477        let tcx = vm_state.tcx;
478
479        // 1. Use worklist-based analysis for as_ptr() chains and branch cases
480        let all_bytes =
481            crate::helpers::mir_utils::collect_all_const_bytes_worklist(tcx, body, target_local);
482        if !all_bytes.is_empty() {
483            let any_invalid = all_bytes.iter().any(|bytes| {
484                !(bytes.last() == Some(&0) && !bytes[..bytes.len().saturating_sub(1)].contains(&0))
485            });
486            if any_invalid {
487                return Some(CheckResult::Failed);
488            }
489            let all_valid = all_bytes.iter().all(|bytes| {
490                bytes.last() == Some(&0) && !bytes[..bytes.len().saturating_sub(1)].contains(&0)
491            });
492            if all_valid {
493                return Some(CheckResult::ProvedByRule);
494            }
495        }
496
497        // 2. Fallback: simple constant byte chain for Aggregate locals
498        if let Some(bytes) =
499            crate::helpers::mir_utils::const_bytes_for_local(tcx, body, target_local)
500        {
501            let valid =
502                bytes.last() == Some(&0) && !bytes[..bytes.len().saturating_sub(1)].contains(&0);
503            return if valid {
504                Some(CheckResult::ProvedByRule)
505            } else {
506                Some(CheckResult::Failed)
507            };
508        }
509
510        // 3. Scan MIR for a single 0_u8 store into the target buffer
511        //    (mirrors legacy nul_store_before_checkpoint logic)
512        if let Some(r) = Self::check_valid_cstr_nul_store(vm_state, checkpoint) {
513            return Some(r);
514        }
515
516        None
517    }
518}