Skip to main content

ud_cli/
lib.rs

1//! Library half of the `ud` CLI.
2//!
3//! The CLI binary is a thin wrapper over the functions exposed here; the
4//! split lets integration tests call the same code path as the binary
5//! without spawning a subprocess.
6
7pub mod solana;
8
9use std::path::Path;
10
11use ud_core::{assert_bytes_equal, Error, Result};
12use ud_translate::compile::AsmWarning;
13
14/// Run the round-trip pipeline on `input`, write the result to `output`,
15/// and verify byte-equality with the input.
16///
17/// The pipeline routes by detected format:
18///
19/// * **ELF64-LE** is parsed via [`ud_format::elf::Elf64File`] and re-emitted.
20///   This actually exercises the format reader/writer, so any drift in
21///   either path is caught here.
22/// * **Anything else** (32-bit ELF, PE, Mach-O, raw bytes) falls through
23///   to a byte-copy. The round-trip contract still holds — it's just the
24///   trivial identity until we grow support.
25///
26/// The shape of this function will not change as later phases replace
27/// `pipeline_bytes` with real decompile-then-compile logic. The contract
28/// is "input bytes equal output bytes or you get an error", forever.
29pub fn roundtrip(input: &Path, output: &Path) -> Result<()> {
30    let bytes = std::fs::read(input).map_err(|source| Error::Io {
31        path: input.to_path_buf(),
32        source,
33    })?;
34
35    let rebuilt = pipeline_bytes(&bytes);
36
37    std::fs::write(output, &rebuilt).map_err(|source| Error::Io {
38        path: output.to_path_buf(),
39        source,
40    })?;
41
42    let written_back = std::fs::read(output).map_err(|source| Error::Io {
43        path: output.to_path_buf(),
44        source,
45    })?;
46
47    assert_bytes_equal(&bytes, &written_back)
48}
49
50/// Apply the round-trip pipeline to in-memory bytes and return the result.
51///
52/// Split out so it's directly testable without filesystem I/O.
53fn pipeline_bytes(bytes: &[u8]) -> Vec<u8> {
54    if ud_format::elf::is_elf64_le(bytes) {
55        if let Ok(elf) = ud_format::elf::Elf64File::parse(bytes) {
56            return elf.write_to_vec();
57        }
58        // ELF that we still can't parse (e.g. malformed header sizes).
59        // Fall through to byte-copy so the round-trip contract holds.
60    }
61    if ud_format::pe::is_pe(bytes) {
62        if let Ok(pe) = ud_format::pe::PeFile::parse(bytes) {
63            return pe.write_to_vec();
64        }
65        // PE-shaped but invalid; fall through to byte-copy.
66    }
67    if ud_format::macho::is_macho64(bytes) {
68        if let Ok(macho) = ud_format::macho::MachoFile::parse(bytes) {
69            return macho.write_to_vec();
70        }
71        // Mach-O-shaped but rejected by v1 (32-bit, unsupported
72        // cputype, fat wrapper); fall through to byte-copy.
73    }
74    if ud_format::wasm::is_wasm(bytes) {
75        if let Ok(wasm) = ud_format::wasm::WasmFile::parse(bytes) {
76            return wasm.write_to_vec();
77        }
78        // WASM-shaped but rejected; fall through to byte-copy.
79    }
80    bytes.to_vec()
81}
82
83/// Result of [`roundtrip_through_source`].
84#[derive(Debug, Clone)]
85pub struct SourceRoundTripReport {
86    /// Whether the rebuilt bytes equal the input bytes.
87    pub byte_identical: bool,
88    /// Length of the input in bytes.
89    pub input_len: usize,
90    /// Length of the rebuilt bytes.
91    pub output_len: usize,
92    /// Offset of the first byte that differs, when the round-trip
93    /// failed; `None` when the result is byte-identical.
94    pub first_diff_offset: Option<usize>,
95    /// 16-byte excerpts of input and output bytes around the first
96    /// divergence. Populated only on mismatch.
97    pub diff_context: Option<DiffContext>,
98    /// `verify_asm` findings produced during the round-trip. Empty
99    /// for a clean decompile output; populated when the `.ud`
100    /// in-flight had `@asm` lines whose text disagreed with their
101    /// pinned bytes.
102    pub warnings: Vec<AsmWarning>,
103}
104
105/// 16-byte windows of input vs rebuilt bytes around the first
106/// divergence, with the divergence offset highlighted.
107#[derive(Debug, Clone)]
108pub struct DiffContext {
109    pub window_start: usize,
110    pub input_window: Vec<u8>,
111    pub output_window: Vec<u8>,
112}
113
114#[derive(Debug, thiserror::Error)]
115pub enum SourceRoundTripError {
116    #[error("input is not a recognised binary format")]
117    UnknownFormat,
118    #[error(transparent)]
119    Io(std::io::Error),
120    #[error(transparent)]
121    Decompile(#[from] ud_translate::decompile::Error),
122    #[error(transparent)]
123    Decompile6502(#[from] ud_translate::decompile::raw6502::Error),
124    #[error(transparent)]
125    ElfFormat(#[from] ud_format::elf::Error),
126    #[error(transparent)]
127    PeFormat(#[from] ud_format::pe::Error),
128    #[error(transparent)]
129    MachoFormat(#[from] ud_format::macho::Error),
130    #[error(transparent)]
131    WasmFormat(#[from] ud_format::wasm::Error),
132    #[error("parse of decompile output failed: {0}")]
133    Parse(String),
134    #[error(transparent)]
135    ElfLower(#[from] ud_translate::compile::ElfLowerError),
136    #[error(transparent)]
137    PeLower(#[from] ud_translate::compile::PeLowerError),
138    #[error(transparent)]
139    MachoLower(#[from] ud_translate::compile::MachoLowerError),
140    #[error(transparent)]
141    RawLower(#[from] ud_translate::compile::RawLowerError),
142    #[error(transparent)]
143    WasmLower(#[from] ud_translate::compile::WasmLowerError),
144}
145
146/// Run `input` through the full source pipeline:
147/// decompile → text → parse → verify_asm → lower_to_elf → write.
148///
149/// Always emits the rebuilt binary. Verification warnings are collected
150/// in the report and don't fail the call. Byte differences between input
151/// and rebuilt also don't fail the call — they appear in the report so
152/// the caller can decide what to do (warn, abort, persist anyway).
153pub fn roundtrip_through_source(
154    input: &Path,
155    output: &Path,
156) -> std::result::Result<SourceRoundTripReport, SourceRoundTripError> {
157    let input_bytes = std::fs::read(input).map_err(SourceRoundTripError::Io)?;
158
159    let (text, warnings, rebuilt) = if ud_format::elf::is_elf64_le(&input_bytes) {
160        let elf = ud_format::elf::Elf64File::parse(&input_bytes)?;
161        let ast = ud_translate::decompile::decompile(&elf)?;
162        let text = ud_ast::emit(&ast);
163        let parsed = ud_translate::compile::parse(&text)
164            .map_err(|e| SourceRoundTripError::Parse(e.to_string()))?;
165        let warnings = ud_translate::compile::verify_asm(&parsed);
166        let rebuilt = ud_translate::compile::lower_to_elf(&parsed)?;
167        (text, warnings, rebuilt)
168    } else if ud_format::pe::is_pe(&input_bytes) {
169        let pe = ud_format::pe::PeFile::parse(&input_bytes)?;
170        let ast = ud_translate::decompile::decompile_pe(&pe);
171        let text = ud_ast::emit(&ast);
172        let parsed = ud_translate::compile::parse(&text)
173            .map_err(|e| SourceRoundTripError::Parse(e.to_string()))?;
174        let warnings = ud_translate::compile::verify_asm(&parsed);
175        let rebuilt = ud_translate::compile::lower_to_pe(&parsed)?;
176        (text, warnings, rebuilt)
177    } else if ud_format::macho::is_macho64(&input_bytes) {
178        let macho = ud_format::macho::MachoFile::parse(&input_bytes)?;
179        let ast = ud_translate::decompile::decompile_macho(&macho);
180        let text = ud_ast::emit(&ast);
181        let parsed = ud_translate::compile::parse(&text)
182            .map_err(|e| SourceRoundTripError::Parse(e.to_string()))?;
183        let warnings = ud_translate::compile::verify_asm(&parsed);
184        let rebuilt = ud_translate::compile::lower_to_macho(&parsed)?;
185        (text, warnings, rebuilt)
186    } else if ud_format::wasm::is_wasm(&input_bytes) {
187        let wasm = ud_format::wasm::WasmFile::parse(&input_bytes)?;
188        let ast = ud_translate::decompile::decompile_wasm(&wasm);
189        let text = ud_ast::emit(&ast);
190        let parsed = ud_translate::compile::parse(&text)
191            .map_err(|e| SourceRoundTripError::Parse(e.to_string()))?;
192        let warnings = ud_translate::compile::verify_asm(&parsed);
193        let rebuilt = ud_translate::compile::lower_to_wasm(&parsed)?;
194        (text, warnings, rebuilt)
195    } else if let Some(load_addr) = raw_6502_load_addr(&input_bytes) {
196        let image = ud_format::raw::RawImage::new(input_bytes.clone(), load_addr);
197        let ast = ud_translate::decompile::decompile_raw_6502(&image)?;
198        let text = ud_ast::emit(&ast);
199        let parsed = ud_translate::compile::parse(&text)
200            .map_err(|e| SourceRoundTripError::Parse(e.to_string()))?;
201        let warnings = ud_translate::compile::verify_asm(&parsed);
202        let rebuilt = ud_translate::compile::lower_to_raw(&parsed)?;
203        (text, warnings, rebuilt)
204    } else {
205        return Err(SourceRoundTripError::UnknownFormat);
206    };
207    let _ = text; // kept for future debug surfacing
208
209    std::fs::write(output, &rebuilt).map_err(SourceRoundTripError::Io)?;
210
211    let first_diff_offset = first_byte_diff(&input_bytes, &rebuilt);
212    let diff_context = first_diff_offset.map(|off| make_diff_context(off, &input_bytes, &rebuilt));
213    Ok(SourceRoundTripReport {
214        byte_identical: first_diff_offset.is_none() && input_bytes.len() == rebuilt.len(),
215        input_len: input_bytes.len(),
216        output_len: rebuilt.len(),
217        first_diff_offset,
218        diff_context,
219        warnings,
220    })
221}
222
223fn make_diff_context(off: usize, input: &[u8], output: &[u8]) -> DiffContext {
224    let window_start = off.saturating_sub(8);
225    let window_end_in = (off + 8).min(input.len());
226    let window_end_out = (off + 8).min(output.len());
227    DiffContext {
228        window_start,
229        input_window: input[window_start..window_end_in].to_vec(),
230        output_window: output[window_start..window_end_out].to_vec(),
231    }
232}
233
234/// Detect "this is a 6502 raw ROM image" inputs. The convention is
235/// that 6502 binaries place vectors (NMI/RESET/IRQ) at the top of
236/// the 16-bit address space, $FFFA-$FFFF. For an image of length L,
237/// the natural load address is `0x10000 - L` so the image extends
238/// exactly to $FFFF.
239///
240/// v0 heuristic: accept files in `[6, 65536]` bytes whose reset
241/// vector at $FFFC under that load address points back into the
242/// image. WozMon (256 bytes, load $FF00, reset $FF00) matches.
243#[must_use]
244pub fn raw_6502_load_addr(bytes: &[u8]) -> Option<u64> {
245    let len = bytes.len();
246    if !(6..=0x10000).contains(&len) {
247        return None;
248    }
249    let load_addr = 0x10000u64 - len as u64;
250    let end = 0x10000u64;
251    let reset_lo_off = usize::try_from(0xFFFCu64 - load_addr).ok()?;
252    let reset_hi_off = reset_lo_off + 1;
253    if reset_hi_off >= len {
254        return None;
255    }
256    let reset = u64::from(u16::from_le_bytes([
257        bytes[reset_lo_off],
258        bytes[reset_hi_off],
259    ]));
260    if reset >= load_addr && reset < end {
261        Some(load_addr)
262    } else {
263        None
264    }
265}
266
267fn first_byte_diff(a: &[u8], b: &[u8]) -> Option<usize> {
268    a.iter()
269        .zip(b)
270        .position(|(x, y)| x != y)
271        .or_else(|| (a.len() != b.len()).then_some(a.len().min(b.len())))
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn pipeline_passes_through_non_elf_bytes() {
280        let bytes = b"\x00\x01\x02\x03not an elf";
281        assert_eq!(pipeline_bytes(bytes), bytes);
282    }
283
284    #[test]
285    fn pipeline_passes_through_elf32() {
286        // Magic + ELFCLASS32 + ELFDATA2LSB → not ELF64, must byte-copy.
287        let mut bytes = vec![0u8; 64];
288        bytes[..4].copy_from_slice(b"\x7fELF");
289        bytes[4] = 1; // ELFCLASS32
290        bytes[5] = 1; // ELFDATA2LSB
291        let out = pipeline_bytes(&bytes);
292        assert_eq!(out, bytes);
293    }
294
295    #[test]
296    fn roundtrip_on_a_temp_file_succeeds() {
297        let dir = std::env::temp_dir();
298        let input = dir.join("ud-cli-rt-in");
299        let output = dir.join("ud-cli-rt-out");
300        std::fs::write(&input, b"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00").unwrap();
301        roundtrip(&input, &output).expect("identity round-trip should succeed");
302        let _ = std::fs::remove_file(&input);
303        let _ = std::fs::remove_file(&output);
304    }
305}