Skip to main content

r2smt_patch/
manifest.rs

1//! Patch manifest: the durable record of every byte r2SMT has changed.
2//!
3//! Manifests are written to JSON next to the patched binary. They
4//! drive both human review and machine rollback ([`crate::apply::rollback_from_manifest`]).
5
6use std::fs;
7use std::path::Path;
8
9use r2smt_common::{Address, Error, Result};
10use r2smt_core::{Confidence, FindingKind};
11use serde::{Deserialize, Serialize};
12
13/// Wire-format version of the manifest.
14///
15/// Bumped on any incompatible schema change. Rollback refuses to
16/// operate on manifests with an unknown version so out-of-date tools
17/// cannot accidentally corrupt newer patches.
18pub const MANIFEST_VERSION: u32 = 1;
19
20/// One byte-level patch operation as recorded in a [`PatchManifest`].
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct PatchRecord {
23    /// Address of the patched instruction.
24    pub address: Address,
25    /// Strategy name (matches `r2smt-report::PatchStrategy::as_str`).
26    pub strategy: String,
27    /// Finding kind that motivated the patch.
28    pub kind: FindingKind,
29    /// Confidence at which the patch was committed.
30    pub confidence: Confidence,
31    /// Lowercase hex of the bytes present at `address` *before* the
32    /// patch was applied. Rollback restores these.
33    pub original_bytes_hex: String,
34    /// Lowercase hex of the bytes the patch wrote at `address`.
35    pub patched_bytes_hex: String,
36    /// Human-readable explanation forwarded from the suggestion engine.
37    pub rationale: String,
38}
39
40impl PatchRecord {
41    /// Decode `original_bytes_hex`.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`Error::Parse`] if the hex string is malformed.
46    pub fn original_bytes(&self) -> Result<Vec<u8>> {
47        hex::decode(&self.original_bytes_hex)
48            .map_err(|e| Error::parse("patch_record.original_bytes_hex", e.to_string()))
49    }
50
51    /// Decode `patched_bytes_hex`.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`Error::Parse`] if the hex string is malformed.
56    pub fn patched_bytes(&self) -> Result<Vec<u8>> {
57        hex::decode(&self.patched_bytes_hex)
58            .map_err(|e| Error::parse("patch_record.patched_bytes_hex", e.to_string()))
59    }
60}
61
62/// Durable record of an r2SMT patch session.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct PatchManifest {
65    /// Schema version (currently [`MANIFEST_VERSION`]).
66    pub manifest_version: u32,
67    /// r2SMT version that produced the manifest.
68    pub r2smt_version: String,
69    /// Display path of the patched binary at apply-time.
70    pub binary: String,
71    /// SHA-256 of the binary before any patch was applied.
72    pub binary_sha256_before: String,
73    /// SHA-256 of the binary after all operations completed.
74    pub binary_sha256_after: String,
75    /// Absolute path of the backup created before patching.
76    pub backup_path: String,
77    /// Operations applied, in execution order.
78    pub operations: Vec<PatchRecord>,
79}
80
81impl PatchManifest {
82    /// Default file name r2SMT uses to persist a manifest next to its
83    /// binary.
84    pub const DEFAULT_FILE_NAME: &'static str = "r2smt.manifest.json";
85
86    /// Render the manifest as pretty-printed JSON.
87    ///
88    /// # Errors
89    ///
90    /// Propagates serialisation failures.
91    pub fn to_json(&self) -> Result<String> {
92        serde_json::to_string_pretty(self)
93            .map_err(|e| Error::parse("patch_manifest", e.to_string()))
94    }
95
96    /// Write the manifest to `path` as pretty-printed JSON.
97    ///
98    /// # Errors
99    ///
100    /// Propagates I/O and serialisation failures.
101    pub fn write_to(&self, path: impl AsRef<Path>) -> Result<()> {
102        let json = self.to_json()?;
103        fs::write(path, json)?;
104        Ok(())
105    }
106
107    /// Load a manifest from `path`.
108    ///
109    /// Refuses to deserialize manifests with a version r2SMT does not
110    /// understand so old binaries cannot corrupt newer patches.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`Error::Parse`] if the file is missing, malformed, or
115    /// written by a future schema version.
116    pub fn read_from(path: impl AsRef<Path>) -> Result<Self> {
117        let raw = fs::read_to_string(path)?;
118        let parsed: Self = serde_json::from_str(&raw)
119            .map_err(|e| Error::parse("patch_manifest", e.to_string()))?;
120        if parsed.manifest_version != MANIFEST_VERSION {
121            return Err(Error::parse(
122                "patch_manifest",
123                format!(
124                    "unsupported manifest version {got} (this build only handles {expected})",
125                    got = parsed.manifest_version,
126                    expected = MANIFEST_VERSION,
127                ),
128            ));
129        }
130        Ok(parsed)
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    #![allow(clippy::unwrap_used)]
137
138    use tempfile::NamedTempFile;
139
140    use super::*;
141
142    fn sample_manifest() -> PatchManifest {
143        PatchManifest {
144            manifest_version: MANIFEST_VERSION,
145            r2smt_version: "0.1.0".into(),
146            binary: "/tmp/sample.exe".into(),
147            binary_sha256_before: "a".repeat(64),
148            binary_sha256_after: "b".repeat(64),
149            backup_path: "/tmp/sample.exe.r2smt.bak".into(),
150            operations: vec![PatchRecord {
151                address: Address(0x40_1050),
152                strategy: "nop_jcc".into(),
153                kind: FindingKind::DeadBranch,
154                confidence: Confidence::High,
155                original_bytes_hex: "7505".into(),
156                patched_bytes_hex: "9090".into(),
157                rationale: "jne is never taken".into(),
158            }],
159        }
160    }
161
162    #[test]
163    fn manifest_round_trips_through_json() {
164        let original = sample_manifest();
165        let json = original.to_json().unwrap();
166        let back: PatchManifest = serde_json::from_str(&json).unwrap();
167        assert_eq!(back, original);
168    }
169
170    #[test]
171    fn manifest_round_trips_through_disk() {
172        let original = sample_manifest();
173        let tmp = NamedTempFile::new().unwrap();
174        original.write_to(tmp.path()).unwrap();
175        let back = PatchManifest::read_from(tmp.path()).unwrap();
176        assert_eq!(back, original);
177    }
178
179    #[test]
180    fn read_from_rejects_unknown_version() {
181        let mut bad = sample_manifest();
182        bad.manifest_version = MANIFEST_VERSION + 1;
183        let tmp = NamedTempFile::new().unwrap();
184        std::fs::write(tmp.path(), bad.to_json().unwrap()).unwrap();
185        let err = PatchManifest::read_from(tmp.path()).unwrap_err();
186        let rendered = err.to_string();
187        assert!(rendered.contains("unsupported manifest version"));
188    }
189
190    #[test]
191    fn patch_record_decodes_hex_round_trip() {
192        let record = &sample_manifest().operations[0];
193        assert_eq!(record.original_bytes().unwrap(), vec![0x75, 0x05]);
194        assert_eq!(record.patched_bytes().unwrap(), vec![0x90, 0x90]);
195    }
196}