Skip to main content

r2smt_patch/
apply.rs

1//! Apply a [`PatchPlan`] through a [`BytePatcher`] and produce the
2//! durable [`PatchManifest`].
3
4use std::path::{Path, PathBuf};
5
6use r2smt_common::{Error, Result};
7use r2smt_ir::byte_patcher::BytePatcher;
8use tracing::{info, warn};
9
10use crate::digest::sha256_hex;
11use crate::manifest::{MANIFEST_VERSION, PatchManifest, PatchRecord};
12use crate::plan::PatchPlan;
13
14/// Inputs to [`apply_plan`] that are not part of the plan itself.
15///
16/// The caller is responsible for creating `backup_path` *before*
17/// invoking the patcher — backups taken after writes have started
18/// would already be corrupted.
19#[derive(Debug, Clone)]
20pub struct ApplyConfig {
21    /// Path of the binary being patched (used to compute integrity
22    /// hashes for the manifest).
23    pub binary_path: PathBuf,
24    /// Path of the full-file backup created before patching.
25    pub backup_path: PathBuf,
26    /// r2SMT version string recorded in the manifest.
27    pub r2smt_version: String,
28}
29
30/// Apply every operation in `plan` through `patcher`, returning the
31/// durable manifest that records what changed.
32///
33/// On a partial failure (any `read` or `write` returning `Err`) the
34/// function aborts immediately and propagates the error; the manifest
35/// for the *partial* run is *not* returned, so the caller must use
36/// the backup at `config.backup_path` to recover.
37///
38/// # Errors
39///
40/// Propagates I/O failures from hashing the binary, plus any error
41/// produced by the underlying [`BytePatcher`].
42pub fn apply_plan(
43    patcher: &mut dyn BytePatcher,
44    plan: &PatchPlan,
45    config: &ApplyConfig,
46) -> Result<PatchManifest> {
47    let binary_sha256_before = sha256_hex(&config.binary_path)?;
48    info!(
49        target: "r2smt::patch",
50        binary = %config.binary_path.display(),
51        ops = plan.operations.len(),
52        skipped = plan.skipped.len(),
53        sha256_before = %binary_sha256_before,
54        "starting patch run"
55    );
56
57    let mut records: Vec<PatchRecord> = Vec::with_capacity(plan.operations.len());
58    for op in &plan.operations {
59        let original = patcher.read_bytes(op.address, op.size)?;
60        if original.len() != op.new_bytes.len() {
61            warn!(
62                target: "r2smt::patch",
63                addr = %op.address,
64                original = original.len(),
65                new = op.new_bytes.len(),
66                "plan size disagreed with read; aborting"
67            );
68            return Err(r2smt_common::Error::parse(
69                "patch_apply",
70                format!(
71                    "size mismatch at {addr}: original {orig}, new {new}",
72                    addr = op.address,
73                    orig = original.len(),
74                    new = op.new_bytes.len(),
75                ),
76            ));
77        }
78        patcher.write_bytes(op.address, &op.new_bytes)?;
79        records.push(PatchRecord {
80            address: op.address,
81            strategy: op.strategy.as_str().to_string(),
82            kind: op.kind,
83            confidence: op.confidence,
84            original_bytes_hex: hex::encode(&original),
85            patched_bytes_hex: hex::encode(&op.new_bytes),
86            rationale: op.rationale.clone(),
87        });
88    }
89
90    let binary_sha256_after = sha256_hex(&config.binary_path)?;
91    info!(
92        target: "r2smt::patch",
93        applied = records.len(),
94        sha256_after = %binary_sha256_after,
95        "patch run completed"
96    );
97
98    Ok(PatchManifest {
99        manifest_version: MANIFEST_VERSION,
100        r2smt_version: config.r2smt_version.clone(),
101        binary: config.binary_path.display().to_string(),
102        binary_sha256_before,
103        binary_sha256_after,
104        backup_path: absolute_or_display(&config.backup_path),
105        operations: records,
106    })
107}
108
109fn absolute_or_display(path: &Path) -> String {
110    path.canonicalize()
111        .map_or_else(|_| path.display().to_string(), |p| p.display().to_string())
112}
113
114/// Restore the original bytes recorded in `manifest`, walking the
115/// operations in reverse order so any chained patches are unwound
116/// last-applied-first.
117///
118/// Each slot is verified before it is restored: the bytes currently at
119/// `record.address` must equal the recorded `patched_bytes`. If they do
120/// not, the target is not in the state this manifest patched — a
121/// different build, an externally-edited file, or overlapping patches
122/// that did not round-trip — so the rollback refuses rather than writing
123/// the recorded "original" bytes over an unrelated layout and silently
124/// corrupting the file. The check is performed against the recorded
125/// bytes rather than a whole-file hash so it works through the
126/// [`BytePatcher`] abstraction and pinpoints the drifting slot.
127///
128/// # Errors
129///
130/// Returns [`r2smt_common::Error::Parse`] if any record has malformed
131/// hex or a slot's current bytes do not match the recorded patch, plus
132/// any error from the underlying [`BytePatcher`].
133pub fn rollback_from_manifest(
134    patcher: &mut dyn BytePatcher,
135    manifest: &PatchManifest,
136) -> Result<()> {
137    info!(
138        target: "r2smt::patch",
139        ops = manifest.operations.len(),
140        binary = %manifest.binary,
141        "starting rollback"
142    );
143    for record in manifest.operations.iter().rev() {
144        let original = record.original_bytes()?;
145        let expected = record.patched_bytes()?;
146        // `apply_plan` guarantees equal lengths at write time, but the
147        // manifest is loaded from disk and could be hand-edited or
148        // foreign-produced. A record whose original and patched bytes
149        // differ in length would read/compare `expected.len()` bytes here
150        // but then write `original.len()` bytes at the same address —
151        // overwriting a different-sized slot and corrupting adjacent
152        // instructions. Reject it rather than restore a wrong-size slot.
153        if original.len() != expected.len() {
154            return Err(Error::parse(
155                "rollback",
156                format!(
157                    "record at {addr} has mismatched byte lengths (original {orig}, \
158                     patched {patched}); the manifest is malformed — refusing to restore",
159                    addr = record.address,
160                    orig = original.len(),
161                    patched = expected.len(),
162                ),
163            ));
164        }
165        let current = patcher.read_bytes(record.address, expected.len())?;
166        if current != expected {
167            return Err(Error::parse(
168                "rollback",
169                format!(
170                    "bytes at {addr} do not match the recorded patch \
171                     ({current} vs {patched}); the target is not in the \
172                     expected post-patch state — refusing to restore",
173                    addr = record.address,
174                    current = hex::encode(&current),
175                    patched = record.patched_bytes_hex,
176                ),
177            ));
178        }
179        patcher.write_bytes(record.address, &original)?;
180    }
181    info!(target: "r2smt::patch", "rollback completed");
182    Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187    #![allow(clippy::unwrap_used)]
188
189    use std::fs;
190    use std::io::Write;
191
192    use r2smt_common::smt::SmtResult;
193    use r2smt_common::{Address, Arch};
194    use r2smt_core::{Confidence, Finding, FindingEvidence, FindingKind};
195    use r2smt_ir::testing::InMemoryBytePatcher;
196    use r2smt_report::PatchStrategy;
197    use r2smt_slicer::condition::BranchCondition;
198    use r2smt_slicer::slice::SliceStatus;
199    use tempfile::NamedTempFile;
200
201    use super::*;
202    use crate::plan::{PlanOperation, build_plan};
203
204    fn dead_branch_finding(address: u64, size: u64) -> Finding {
205        Finding {
206            address: Address(address),
207            function: Address(0x40_1000),
208            mnemonic: "jne".into(),
209            condition: BranchCondition::NotEqual,
210            formula: "ZF == 0".into(),
211            formula_pretty: "(ZF == 0)".into(),
212            formula_z3_pretty: None,
213            verdict: SmtResult::AlwaysFalse,
214            kind: FindingKind::DeadBranch,
215            confidence: Confidence::High,
216            taken_target: Some(Address(0x40_1080)),
217            fallthrough_target: Some(Address(address + size)),
218            operands: Vec::new(),
219            is_thumb: false,
220            evidence: FindingEvidence {
221                slice_status: SliceStatus::Complete,
222                statement_count: 0,
223                input_count: 0,
224                inputs: vec![],
225                unknown_count: 0,
226                upstream_resolved_to: None,
227                oracle_agreement: None,
228            },
229            pseudocode: None,
230        }
231    }
232
233    fn writable_temp_file_with_bytes(bytes: &[u8]) -> NamedTempFile {
234        let mut tmp = NamedTempFile::new().unwrap();
235        tmp.write_all(bytes).unwrap();
236        tmp.flush().unwrap();
237        tmp
238    }
239
240    #[test]
241    fn apply_records_original_and_new_bytes() {
242        let bytes = vec![0x75, 0x05, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90];
243        let tmp = writable_temp_file_with_bytes(&bytes);
244        let mut patcher = InMemoryBytePatcher::new(Address(0x40_1050), bytes);
245        let finding = dead_branch_finding(0x40_1050, 2);
246        let plan = build_plan(&[finding], Confidence::High, Arch::X86_64, &mut patcher).unwrap();
247        assert_eq!(plan.operations.len(), 1);
248
249        let config = ApplyConfig {
250            binary_path: tmp.path().to_path_buf(),
251            backup_path: tmp.path().with_extension("bak"),
252            r2smt_version: "test".into(),
253        };
254        let manifest = apply_plan(&mut patcher, &plan, &config).unwrap();
255
256        assert_eq!(manifest.operations.len(), 1);
257        let record = &manifest.operations[0];
258        assert_eq!(record.address, Address(0x40_1050));
259        assert_eq!(record.original_bytes_hex, "7505");
260        assert_eq!(record.patched_bytes_hex, "9090");
261        assert_eq!(record.strategy, PatchStrategy::NopJcc.as_str());
262        // In-memory patcher mutates the buffer; verify the write
263        // actually replaced the original bytes.
264        assert_eq!(&patcher.bytes[0..2], &[0x90, 0x90]);
265    }
266
267    #[test]
268    fn rollback_rejects_a_manifest_record_with_mismatched_byte_lengths() {
269        // A hand-edited / foreign manifest whose original and patched
270        // bytes differ in length would read and compare `patched.len()`
271        // bytes but then write `original.len()` bytes at the same address
272        // — a wrong-size restore that corrupts adjacent instructions.
273        // Rollback must reject it and leave the target untouched.
274        let mut patcher =
275            InMemoryBytePatcher::new(Address(0x40_1050), vec![0x90, 0x90, 0x00, 0x00]);
276        let manifest = PatchManifest {
277            manifest_version: MANIFEST_VERSION,
278            r2smt_version: "test".into(),
279            binary: "/x".into(),
280            binary_sha256_before: String::new(),
281            binary_sha256_after: String::new(),
282            backup_path: String::new(),
283            operations: vec![PatchRecord {
284                address: Address(0x40_1050),
285                strategy: PatchStrategy::NopJcc.as_str().to_string(),
286                kind: FindingKind::DeadBranch,
287                confidence: Confidence::High,
288                original_bytes_hex: "750500".into(), // 3 bytes
289                patched_bytes_hex: "9090".into(),    // 2 bytes
290                rationale: "test".into(),
291            }],
292        };
293        let err = rollback_from_manifest(&mut patcher, &manifest).unwrap_err();
294        assert!(
295            format!("{err}").contains("mismatched byte lengths"),
296            "{err}"
297        );
298        assert_eq!(
299            patcher.bytes,
300            vec![0x90, 0x90, 0x00, 0x00],
301            "a rejected rollback must not write anything"
302        );
303    }
304
305    #[test]
306    fn rollback_restores_original_bytes() {
307        let bytes = vec![0x75, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
308        let tmp = writable_temp_file_with_bytes(&bytes);
309        let mut patcher = InMemoryBytePatcher::new(Address(0x40_1050), bytes.clone());
310        let finding = dead_branch_finding(0x40_1050, 2);
311        let plan = build_plan(&[finding], Confidence::High, Arch::X86_64, &mut patcher).unwrap();
312        let config = ApplyConfig {
313            binary_path: tmp.path().to_path_buf(),
314            backup_path: tmp.path().with_extension("bak"),
315            r2smt_version: "test".into(),
316        };
317        let manifest = apply_plan(&mut patcher, &plan, &config).unwrap();
318
319        // The plan wrote NOPs; ensure the buffer now diverges from
320        // the original.
321        assert_ne!(&patcher.bytes[0..2], &bytes[0..2]);
322
323        // Roll back and confirm the original bytes are restored.
324        rollback_from_manifest(&mut patcher, &manifest).unwrap();
325        assert_eq!(&patcher.bytes[0..2], &bytes[0..2]);
326    }
327
328    #[test]
329    fn rollback_refuses_when_current_bytes_do_not_match_the_patch() {
330        // The manifest records the patched bytes so rollback can confirm
331        // the target is still in the post-patch state. If the file has
332        // drifted (a different build, an external edit) the current bytes
333        // will not match, and restoring the recorded "original" over an
334        // unrelated layout would corrupt it — so rollback must refuse.
335        let bytes = vec![0x75, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
336        let tmp = writable_temp_file_with_bytes(&bytes);
337        let mut patcher = InMemoryBytePatcher::new(Address(0x40_1050), bytes.clone());
338        let finding = dead_branch_finding(0x40_1050, 2);
339        let plan = build_plan(&[finding], Confidence::High, Arch::X86_64, &mut patcher).unwrap();
340        let config = ApplyConfig {
341            binary_path: tmp.path().to_path_buf(),
342            backup_path: tmp.path().with_extension("bak"),
343            r2smt_version: "test".into(),
344        };
345        let manifest = apply_plan(&mut patcher, &plan, &config).unwrap();
346
347        // Simulate the file drifting away from the recorded patch.
348        patcher.bytes[0] = 0xAB;
349
350        let err = rollback_from_manifest(&mut patcher, &manifest).unwrap_err();
351        assert!(err.to_string().contains("do not match"), "{err}");
352        // The drifted byte is left untouched — no blind restore happened.
353        assert_eq!(patcher.bytes[0], 0xAB);
354    }
355
356    #[test]
357    fn apply_aborts_when_patcher_write_fails() {
358        // Use a tiny buffer so the second write goes past the end.
359        let bytes = vec![0x75, 0x05];
360        let tmp = writable_temp_file_with_bytes(&bytes);
361        let mut patcher = InMemoryBytePatcher::new(Address(0x40_1050), bytes);
362        let mut plan = PatchPlan::default();
363        plan.operations.push(PlanOperation {
364            address: Address(0x40_1050),
365            strategy: PatchStrategy::NopJcc,
366            kind: FindingKind::DeadBranch,
367            confidence: Confidence::High,
368            size: 2,
369            new_bytes: vec![0x90, 0x90],
370            rationale: "test".into(),
371        });
372        // Second operation writes past the end of the in-memory
373        // buffer and must trigger an Err from the patcher.
374        plan.operations.push(PlanOperation {
375            address: Address(0x40_1060),
376            strategy: PatchStrategy::NopJcc,
377            kind: FindingKind::DeadBranch,
378            confidence: Confidence::High,
379            size: 2,
380            new_bytes: vec![0x90, 0x90],
381            rationale: "test".into(),
382        });
383        let config = ApplyConfig {
384            binary_path: tmp.path().to_path_buf(),
385            backup_path: tmp.path().with_extension("bak"),
386            r2smt_version: "test".into(),
387        };
388        let err = apply_plan(&mut patcher, &plan, &config).unwrap_err();
389        let msg = err.to_string();
390        assert!(msg.contains("past end") || msg.contains("address"));
391    }
392
393    #[test]
394    fn apply_captures_sha256_from_disk_into_manifest() {
395        let bytes = vec![0x75, 0x05];
396        let tmp = writable_temp_file_with_bytes(&bytes);
397        let mut patcher = InMemoryBytePatcher::new(Address(0x40_1050), bytes);
398        let finding = dead_branch_finding(0x40_1050, 2);
399        let plan = build_plan(&[finding], Confidence::High, Arch::X86_64, &mut patcher).unwrap();
400        let config = ApplyConfig {
401            binary_path: tmp.path().to_path_buf(),
402            backup_path: tmp.path().with_extension("bak"),
403            r2smt_version: "test".into(),
404        };
405
406        // Capture the file's SHA-256 before apply. The in-memory
407        // patcher does not write to the file, so the post hash also
408        // matches `pre` — the assertion below pins that the manifest
409        // truly reads from disk both times rather than just echoing
410        // an in-memory value.
411        let pre = sha256_hex(tmp.path()).unwrap();
412        let manifest = apply_plan(&mut patcher, &plan, &config).unwrap();
413        assert_eq!(manifest.binary_sha256_before, pre);
414        assert_eq!(manifest.binary_sha256_after, pre);
415
416        // Now rewrite the underlying file to simulate the effect of a
417        // real disk-backed patcher and verify the manifest's hashes
418        // would differ if the file actually changed between the two
419        // reads.
420        fs::write(tmp.path(), [0x90, 0x90]).unwrap();
421        let post = sha256_hex(tmp.path()).unwrap();
422        assert_ne!(pre, post, "rewriting the file must change its hash");
423    }
424}