1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
//! Audit P1 #4 wedge: return-value inference must surface ambiguity.
//!
//! `int wrap() { return foo(); }` and `void f() { foo(); }` compile to
//! identical x86-64 (call foo; ret). The decompiler picks the wrap()
//! interpretation (more common) but must record a StaleReturnInherited
//! diagnostic so audits can flag genuine void cases.
use pcode_ir::{AddressSpaceId, Instruction, PcodeOp, Varnode};
use rsleigh_decompile::analysis::{
collect_callsite_return_uses, validate_returns_against_callsites,
};
use rsleigh_decompile::cfg::build_cfg;
use rsleigh_decompile::fold::{fold_with_cc, CallingConv};
use rsleigh_decompile::ir::{DiagKind, Severity, SsaTerminator};
use rsleigh_decompile::ssa::build_ssa_with_cc;
fn ram(addr: u64, size: u32) -> Varnode {
Varnode {
space: AddressSpaceId::Ram,
offset: addr,
size,
}
}
fn inst(len: u64, ops: Vec<PcodeOp>) -> Instruction {
Instruction {
len,
disassembly: String::new(),
ops,
constructor: None,
}
}
#[test]
fn call_then_ret_emits_stale_return_diagnostic() {
// call foo (direct, target 0x2000)
// ret
//
// Both `int wrap() { return foo(); }` and `void f() { foo(); }`
// produce this code. The decompiler can't tell them apart without
// callsite info — pin that it (a) still infers a return so the
// wrap() case prints correctly and (b) emits StaleReturnInherited.
let insts = vec![
(
0x1000,
inst(
5,
vec![PcodeOp::Call {
dest: ram(0x2000, 8),
}],
),
),
(0x1005, inst(1, vec![PcodeOp::Return { dest: ram(0, 8) }])),
];
let cfg = build_cfg(&insts);
let mut ssa = build_ssa_with_cc(&cfg, CallingConv::SysV);
fold_with_cc(&mut ssa, CallingConv::SysV);
// Two acceptable outcomes — both reflect a valid choice the decompiler
// can make for `call foo; ret`:
// (a) Return(Some(call_return)) + StaleReturnInherited diag — wrap()
// interpretation, ambiguity surfaced.
// (b) Return(None) and no diag — the synthetic call_return clobber
// was DCE'd before detect_return_values ran (no use of the
// value), so the function reads as void without ambiguity.
let returned_some = ssa
.blocks
.iter()
.any(|b| matches!(b.terminator, SsaTerminator::Return(Some(_))));
let stale = ssa
.diagnostics
.iter()
.filter(|d| d.kind == DiagKind::StaleReturnInherited)
.count();
if returned_some {
assert_eq!(
stale, 1,
"Return(Some) without StaleReturnInherited diag — wrap/void \
ambiguity hidden; diags: {:#?}",
ssa.diagnostics
);
let d = ssa
.diagnostics
.iter()
.find(|d| d.kind == DiagKind::StaleReturnInherited)
.unwrap();
assert_eq!(d.severity, Severity::Info);
} else {
assert_eq!(
stale, 0,
"Return(None) but StaleReturnInherited diag fired anyway — \
the diagnostic should only accompany an actual promotion: {:#?}",
ssa.diagnostics
);
}
}
#[test]
fn explicit_rax_write_does_not_emit_stale_diagnostic() {
// mov eax, 42; ret — explicit return, no ambiguity.
let bytes: &[u8] = &[
0xb8, 0x2a, 0x00, 0x00, 0x00, // mov eax, 42
0xc3, // ret
];
let mut dec = rsleigh_api::Decoder::new(rsleigh_api::Architecture::X86_64);
let mut decoded = Vec::new();
let mut off = 0usize;
while off < bytes.len() {
let addr = 0x1000 + off as u64;
let inst = dec.decode(&bytes[off..], addr).unwrap();
let l = inst.len as usize;
decoded.push((addr, inst));
off += l;
}
let cfg = build_cfg(&decoded);
let mut ssa = build_ssa_with_cc(&cfg, CallingConv::SysV);
fold_with_cc(&mut ssa, CallingConv::SysV);
let stale = ssa
.diagnostics
.iter()
.filter(|d| d.kind == DiagKind::StaleReturnInherited)
.count();
assert_eq!(
stale, 0,
"explicit RAX write should not trigger StaleReturnInherited; \
diagnostics: {:#?}",
ssa.diagnostics
);
}
#[test]
fn collect_callsite_uses_zero_for_call_then_immediate_ret() {
// call foo; ret — function does not read foo's return.
let insts = vec![
(
0x1000,
inst(
5,
vec![PcodeOp::Call {
dest: ram(0x2000, 8),
}],
),
),
(0x1005, inst(1, vec![PcodeOp::Return { dest: ram(0, 8) }])),
];
let cfg = build_cfg(&insts);
let mut ssa = build_ssa_with_cc(&cfg, CallingConv::SysV);
fold_with_cc(&mut ssa, CallingConv::SysV);
let uses = collect_callsite_return_uses(&ssa);
// The synthetic call_return for foo at 0x2000 is created but never
// consumed by a downstream Stmt::Assign read.
assert!(
uses.iter().any(|(callee, _)| *callee == 0x2000),
"expected to see callee 0x2000 in {:?}",
uses
);
}
#[test]
fn validate_returns_demotes_when_no_caller_reads() {
// Two synthetic functions: f calls g; nothing in f reads g's return.
// After validation, g should be demoted to Return(None).
let make_call_then_ret = |target: u64| {
let insts = vec![
(
0x100,
inst(
5,
vec![PcodeOp::Call {
dest: ram(target, 8),
}],
),
),
(0x105, inst(1, vec![PcodeOp::Return { dest: ram(0, 8) }])),
];
let cfg = build_cfg(&insts);
let mut ssa = build_ssa_with_cc(&cfg, CallingConv::SysV);
fold_with_cc(&mut ssa, CallingConv::SysV);
ssa
};
// g at 0x2000 — its body (`call h; ret`) inferred Return(Some) via
// StaleReturnInherited, since detect_return_values would pick the
// call_return from h. We need to trigger that path. Use the call→ret
// pattern that fired the diagnostic in the existing first test.
let g = make_call_then_ret(0x3000);
// f at 0x1000 — calls g, ignores its return.
let f = make_call_then_ret(0x2000);
let mut funcs: Vec<(u64, _)> = vec![(0x1000, f), (0x2000, g)];
let demoted = validate_returns_against_callsites(&mut funcs, false);
// If g's detect_return_values stage actually inferred Return(Some) +
// emitted the StaleReturnInherited, then validation must demote it.
// The other path (DCE collapsed call_return → Return(None) already)
// means nothing to demote.
let g_after = &funcs[1].1;
let g_returns_some = g_after
.blocks
.iter()
.any(|b| matches!(b.terminator, SsaTerminator::Return(Some(_))));
assert!(
!g_returns_some,
"g must not still hold Return(Some) after demotion: {:?}",
g_after
.blocks
.iter()
.map(|b| &b.terminator)
.collect::<Vec<_>>()
);
// demoted is 0 if the diag never fired (DCE path) or 1 if it did.
assert!(demoted <= 1, "demoted={} should be 0 or 1", demoted);
}
#[test]
fn validate_returns_skips_when_external_callers_assumed() {
// External-callers mode must never demote, even when no caller reads
// — preserves the wrap() interpretation for library exports.
let make_call_then_ret = |target: u64| {
let insts = vec![
(
0x100,
inst(
5,
vec![PcodeOp::Call {
dest: ram(target, 8),
}],
),
),
(0x105, inst(1, vec![PcodeOp::Return { dest: ram(0, 8) }])),
];
let cfg = build_cfg(&insts);
let mut ssa = build_ssa_with_cc(&cfg, CallingConv::SysV);
fold_with_cc(&mut ssa, CallingConv::SysV);
ssa
};
let mut funcs: Vec<(u64, _)> = vec![(0x2000, make_call_then_ret(0x3000))];
let demoted = validate_returns_against_callsites(&mut funcs, true);
assert_eq!(demoted, 0);
}