Skip to main content

ab_contracts_tooling/
convert.rs

1//! Convert RISC-V ELF `cdylib` into Abundance contract file format
2
3use ab_aligned_buffer::SharedAlignedBuffer;
4use ab_aligned_buffer::alignment::Align16;
5use ab_contract_file::{CONTRACT_FILE_MAGIC, ContractFileHeader, ContractFileMethodMetadata};
6use ab_contracts_common::metadata::decode::MetadataDecoder;
7use ab_contracts_common::{HOST_CALL_FN, HOST_CALL_FN_IMPORT, METADATA_STATIC_NAME_PREFIX};
8use ab_io_type::trivial_type::TrivialType;
9use anyhow::Context;
10use object::elf::{
11    EF_RISCV_RVC, ELFCLASS64, ELFDATA2LSB, ELFMAG, ELFOSABI_GNU, EM_RISCV, ET_DYN, FileHeader64,
12    FileVersion, Ident, R_RISCV_JUMP_SLOT, STB_GLOBAL, STV_DEFAULT,
13};
14use object::read::elf::{ElfFile, ElfFile64, FileHeader};
15use object::{
16    CompressedData, CompressionFormat, LittleEndian, Object, ObjectSection, ObjectSymbol,
17    ObjectSymbolTable, RelocationFlags, RelocationTarget, SymbolKind, SymbolSection, U16, U32, U64,
18    Wrap,
19};
20use std::collections::HashMap;
21use std::iter;
22use tracing::{debug, trace};
23
24fn is_correct_header(header: &FileHeader64<LittleEndian>) -> bool {
25    let expected_header = FileHeader64 {
26        e_ident: Ident {
27            magic: ELFMAG,
28            class: ELFCLASS64,
29            data: ELFDATA2LSB,
30            version: FileVersion::from_inner(1),
31            os_abi: ELFOSABI_GNU,
32            abi_version: 0,
33            padding: [0; _],
34        },
35        e_type: U16::new(LittleEndian, ET_DYN),
36        e_machine: U16::new(LittleEndian, EM_RISCV),
37        e_version: U32::new(LittleEndian, 1),
38        e_entry: U64::new(LittleEndian, 0),
39        e_phoff: header.e_phoff,
40        e_shoff: header.e_shoff,
41        e_flags: U32::new(LittleEndian, header.e_flags(LittleEndian) & EF_RISCV_RVC),
42        e_ehsize: U16::new(LittleEndian, 64),
43        e_phentsize: header.e_phentsize,
44        e_phnum: header.e_phnum,
45        e_shentsize: header.e_shentsize,
46        e_shnum: header.e_shnum,
47        e_shstrndx: header.e_shstrndx,
48    };
49
50    // Should have been just `==`, but https://github.com/gimli-rs/object/issues/830
51    object::pod::bytes_of(header) == object::pod::bytes_of(&expected_header)
52}
53
54fn check_relocations(elf: &ElfFile<'_, FileHeader64<LittleEndian>>) -> anyhow::Result<()> {
55    let mut dynamic_relocations = elf.dynamic_relocations().into_iter().flatten();
56    let maybe_first_relocation = dynamic_relocations.next();
57
58    if dynamic_relocations.next().is_some() {
59        return Err(anyhow::anyhow!(
60            "Only a single PLT relocation for host function call import is allowed, make sure to \
61            build an optimized cdylib"
62        ));
63    }
64
65    let Some((address, relocation)) = maybe_first_relocation else {
66        return Ok(());
67    };
68
69    debug!(
70        %address,
71        ?relocation,
72        "Found a single relocation"
73    );
74
75    // TODO: There is no such relocation in `object` crate yet:
76    //  https://github.com/gimli-rs/object/issues/833
77    if relocation.flags()
78        != (RelocationFlags::Elf {
79            r_type: R_RISCV_JUMP_SLOT,
80        })
81    {
82        return Err(anyhow::anyhow!("Unexpected relocation: {relocation:?}"));
83    }
84
85    let RelocationTarget::Symbol(symbol_index) = relocation.target() else {
86        return Err(anyhow::anyhow!(
87            "Only a single PLT relocation for host function call import is allowed, make sure to \
88            build an optimized cdylib"
89        ));
90    };
91
92    let sym = elf
93        .dynamic_symbol_table()
94        .context("Failed to get dynamic symbol table")?
95        .symbol_by_index(symbol_index)
96        .context("Failed to get relocation symbol by its index")?;
97
98    let name = sym
99        .name()
100        .with_context(|| format!("Failed to get relocation symbol name: {relocation:?} {sym:?}"))?;
101    debug!(
102        %name,
103        "PLT relocation name"
104    );
105
106    if name != HOST_CALL_FN_IMPORT {
107        return Err(anyhow::anyhow!(
108            "Unexpected PLT relocation name {name}: {relocation:?} {sym:?}"
109        ));
110    }
111
112    if relocation.addend() != 0 || relocation.has_implicit_addend() {
113        return Err(anyhow::anyhow!(
114            "Unexpected PLT relocation {name}: {relocation:?} {sym:?}"
115        ));
116    }
117
118    Ok(())
119}
120
121#[derive(Debug, Copy, Clone)]
122struct ParsedSections {
123    /// Offset of the `ab-contract-metadata` section in the input file, relative to the beginning
124    /// of the file
125    metadata_section_offset: u64,
126    /// Size of the metadata section in the input file
127    metadata_section_size: u64,
128    /// Offset of the `.rodata` section in the input file, relative to the beginning of the file
129    rodata_section_offset: u64,
130    /// Size of the read-only data section in the input file
131    rodata_section_size: u64,
132    /// Padding between `ab-contract-metadata` and `.rodata` sections (if any) in the input file
133    metadata_rodata_padding: u64,
134    /// Size of the read-only memory region once `ab-contract-metadata` and `.rodata` sections are
135    /// loaded into memory, includes possible padding at the end before the `.text` section
136    ro_memory_size: u64,
137    /// Offset of the `.text` section in the input file, relative to the beginning of the file
138    code_section_offset: u64,
139    /// Size of the `.text` section in the input file
140    code_section_size: u64,
141}
142
143fn parse_sections(elf: &ElfFile<'_, FileHeader64<LittleEndian>>) -> anyhow::Result<ParsedSections> {
144    let mut maybe_metadata_section = None;
145    let mut maybe_rodata_section = None;
146    let mut maybe_code_section = None;
147
148    for section in elf.sections() {
149        // TODO: This log is not very usable right now:
150        //  https://github.com/gimli-rs/object/issues/834
151        // trace!(?section, "Processing section");
152        trace!(name = %section.name().unwrap_or_default(), "Processing section");
153
154        match section.name().context("Failed to get section name")? {
155            "ab-contract-metadata" => {
156                let CompressedData {
157                    format,
158                    data: _,
159                    uncompressed_size,
160                } = section
161                    .compressed_data()
162                    .context("Failed to get section data")?;
163                if !matches!(format, CompressionFormat::None) {
164                    return Err(anyhow::anyhow!(
165                        "Section `ab-contract-metadata` is compressed with {format:?}, but shouldn't be"
166                    ));
167                }
168                if uncompressed_size != section.size() {
169                    return Err(anyhow::anyhow!(
170                        "Section `ab-contract-metadata` has unexpected paddings: file size \
171                        {uncompressed_size} != in-memory size {}",
172                        section.size()
173                    ));
174                }
175                maybe_metadata_section.replace(section);
176            }
177            ".rodata" => {
178                let CompressedData {
179                    format,
180                    data: _,
181                    uncompressed_size,
182                } = section
183                    .compressed_data()
184                    .context("Failed to get section data")?;
185                if !matches!(format, CompressionFormat::None) {
186                    return Err(anyhow::anyhow!(
187                        "Section `.rodata` is compressed with {format:?}, but shouldn't be"
188                    ));
189                }
190                if uncompressed_size != section.size() {
191                    return Err(anyhow::anyhow!(
192                        "Section `.rodata` has unexpected paddings: file size \
193                        {uncompressed_size} != in-memory size {}",
194                        section.size()
195                    ));
196                }
197                maybe_rodata_section.replace(section);
198            }
199            ".text" => {
200                let CompressedData {
201                    format,
202                    data: _,
203                    uncompressed_size,
204                } = section
205                    .compressed_data()
206                    .context("Failed to get section data")?;
207                if !matches!(format, CompressionFormat::None) {
208                    return Err(anyhow::anyhow!(
209                        "Section `.text` is compressed with {format:?}, but shouldn't be"
210                    ));
211                }
212                if uncompressed_size != section.size() {
213                    return Err(anyhow::anyhow!(
214                        "Section `.text` has unexpected paddings: file size \
215                        {uncompressed_size} != in-memory size {}",
216                        section.size()
217                    ));
218                }
219                maybe_code_section.replace(section);
220            }
221            _ => {
222                // Ignore everything else
223            }
224        }
225    }
226
227    let Some(metadata_section) = maybe_metadata_section else {
228        return Err(anyhow::anyhow!("Section `ab-contract-metadata` not found"));
229    };
230    let Some(code_section) = maybe_code_section else {
231        return Err(anyhow::anyhow!("Section `.text` not found"));
232    };
233
234    let metadata_section_address = metadata_section.address();
235    let (metadata_section_offset, metadata_section_size) = metadata_section
236        .file_range()
237        .context("Failed to get `ab-contract-metadata` section range")?;
238    let code_section_address = code_section.address();
239    let (code_section_offset, code_section_size) = code_section
240        .file_range()
241        .context("Failed to get `.text` section range")?;
242
243    let (rodata_section_address, (rodata_section_offset, rodata_section_size)) =
244        match maybe_rodata_section {
245            Some(rodata_section) => (
246                rodata_section.address(),
247                rodata_section
248                    .file_range()
249                    .context("Failed to get `.rodata` section range")?,
250            ),
251            None => (metadata_section_address, (metadata_section_offset, 0)),
252        };
253
254    if metadata_section_offset.max(rodata_section_offset) > code_section_offset {
255        return Err(anyhow::anyhow!(
256            "`.text` section must be after `.rodata` and `ab-contract-metadata` sections: \
257            metadata_section_offset={metadata_section_offset}, \
258            rodata_section_offset={rodata_section_offset}, \
259            code_section_offset={code_section_offset}"
260        ));
261    }
262
263    // Calculate in-memory read-only data size from addresses, such that after loading everything is
264    // correct relatively to each other, even though some bytes may, technically, not belong to the
265    // original read-only memory as such
266    let Some(ro_memory_size) =
267        code_section_address.checked_sub(metadata_section_address.min(rodata_section_address))
268    else {
269        return Err(anyhow::anyhow!(
270            "`.text` section must be after `.rodata` and `ab-contract-metadata` sections: \
271            metadata_section_address={metadata_section_address}, \
272            rodata_section_address={rodata_section_address}, \
273            code_section_address={code_section_address}"
274        ));
275    };
276
277    let metadata_rodata_padding = if metadata_section_address < rodata_section_address {
278        (rodata_section_address - metadata_section_address) - metadata_section_size
279    } else {
280        (metadata_section_address - rodata_section_address) - rodata_section_size
281    };
282
283    Ok(ParsedSections {
284        metadata_section_offset,
285        metadata_section_size,
286        rodata_section_offset,
287        rodata_section_size,
288        metadata_rodata_padding,
289        ro_memory_size,
290        code_section_offset,
291        code_section_size,
292    })
293}
294
295fn check_imports(elf: &ElfFile<'_, FileHeader64<LittleEndian>>) -> anyhow::Result<()> {
296    let mut imports = elf.imports().context("Failed to get imports")?;
297
298    let maybe_host_call_fn_import = imports.next().transpose()?;
299    if let Some(import) = maybe_host_call_fn_import
300        && import.name().into_name() != Some(HOST_CALL_FN_IMPORT.as_bytes())
301    {
302        return Err(anyhow::anyhow!(
303            "Expected import `{HOST_CALL_FN_IMPORT}`, got `{}`",
304            String::from_utf8_lossy(import.name().into_name().unwrap_or_default())
305        ));
306    }
307
308    if imports.next().is_some() {
309        return Err(anyhow::anyhow!(
310            "Expected at most one import, got {}",
311            imports.count() + 1 + usize::from(maybe_host_call_fn_import.is_some())
312        ));
313    }
314
315    Ok(())
316}
317
318#[derive(Debug, Copy, Clone)]
319struct ParsedExport {
320    offset: u64,
321    size: u64,
322}
323
324fn parse_exports<'a>(
325    elf: &'a ElfFile<'a, FileHeader64<LittleEndian>>,
326) -> anyhow::Result<HashMap<&'a str, ParsedExport>> {
327    elf.dynamic_symbols()
328        .enumerate()
329        .filter_map(|(index, symbol)| {
330            // TODO: This log is not very usable right now:
331            //  https://github.com/gimli-rs/object/issues/834
332            // trace!(
333            //     %index,
334            //     ?symbol,
335            //     "Processing symbol"
336            // );
337
338            let name = match symbol.name() {
339                Ok(name) => name,
340                Err(error) => return Some(Err(error).context("Failed to get symbol name")),
341            };
342            let elf_symbol = symbol.elf_symbol();
343
344            if elf_symbol.st_bind() != STB_GLOBAL {
345                return Some(Err(anyhow::anyhow!(
346                    "Non-STB_GLOBAL symbol {name}: {symbol:?}"
347                )));
348            }
349            if elf_symbol.st_visibility() != STV_DEFAULT {
350                return Some(Err(anyhow::anyhow!(
351                    "Non-STV_DEFAULT symbol {name}: {symbol:?}"
352                )));
353            }
354            if elf_symbol.st_shndx.get(LittleEndian).is_reserved() {
355                return Some(Err(anyhow::anyhow!(
356                    "Unexpected reserved section index for symbol {name}: {symbol:?}"
357                )));
358            }
359
360            match symbol.kind() {
361                SymbolKind::Unknown => {
362                    if !(symbol.size() == 0 && name == HOST_CALL_FN_IMPORT) {
363                        return Some(Err(anyhow::anyhow!(
364                            "Unexpected unknown symbol {name}: {symbol:?}"
365                        )));
366                    }
367
368                    None
369                }
370                SymbolKind::Text => {
371                    let SymbolSection::Section(section_index) = symbol.section() else {
372                        return Some(Err(anyhow::anyhow!(
373                            "Unexpected section type for symbol {name}: {symbol:?}"
374                        )));
375                    };
376                    let section = match elf.section_by_index(section_index) {
377                        Ok(section) => section,
378                        Err(error) => {
379                            return Some(Err(error).context(format!(
380                                "Failed to get section {section_index} for symbol {name}"
381                            )));
382                        }
383                    };
384                    let Some(offset_within_section) =
385                        symbol.address().checked_sub(section.address())
386                    else {
387                        return Some(Err(anyhow::anyhow!(
388                            "Invalid offset calculation for symbol {name}: \
389                            address {} < section address {}",
390                            symbol.address(),
391                            section.address()
392                        )));
393                    };
394
395                    let Some((section_offset, _section_size)) = section.file_range() else {
396                        return Some(Err(anyhow::anyhow!(
397                            "Failed to get file range for section {section_index} for symbol {name}"
398                        )));
399                    };
400                    let offset = section_offset + offset_within_section;
401                    let size = symbol.size();
402                    debug!(
403                        %index,
404                        %name,
405                        %offset,
406                        %size,
407                        "Found export function"
408                    );
409
410                    Some(Ok((name, ParsedExport { offset, size })))
411                }
412                SymbolKind::Data => {
413                    if !name.starts_with(METADATA_STATIC_NAME_PREFIX) {
414                        return Some(Err(anyhow::anyhow!(
415                            "Unexpected STT_OBJECT {name}: {symbol:?}"
416                        )));
417                    }
418
419                    None
420                }
421                _ => Some(Err(anyhow::anyhow!("Unexpected symbol {name}: {symbol:?}"))),
422            }
423        })
424        .collect()
425}
426
427fn extract_host_call_fn_offset(
428    input_file: &[u8],
429    parsed_exports: &mut HashMap<&str, ParsedExport>,
430) -> anyhow::Result<u64> {
431    let Some(host_call_fn) = parsed_exports.remove(HOST_CALL_FN) else {
432        return Ok(0);
433    };
434
435    if ![size_of::<u16>() as u64, size_of::<u32>() as u64].contains(&host_call_fn.size) {
436        return Err(anyhow::anyhow!(
437            "Host call function {HOST_CALL_FN} has invalid size {}",
438            host_call_fn.size
439        ));
440    }
441    let host_call_fn_offset = host_call_fn.offset;
442    input_file
443        .get(host_call_fn_offset as usize..)
444        .with_context(|| {
445            format!(
446                "Host call address {host_call_fn_offset} out of range of input file ({} bytes)",
447                input_file.len()
448            )
449        })?
450        .get(..host_call_fn.size as usize)
451        .context("Not enough bytes to get instructions of host call function")?;
452
453    Ok(host_call_fn_offset)
454}
455
456fn parse_metadata_methods(
457    parsed_exports: &mut HashMap<&str, ParsedExport>,
458    metadata_bytes: &[u8],
459) -> anyhow::Result<Vec<ParsedExport>> {
460    let mut metadata_methods = Vec::new();
461
462    let mut metadata_decoder = MetadataDecoder::new(metadata_bytes);
463
464    while let Some(maybe_metadata_item) = metadata_decoder.decode_next() {
465        let metadata_item = maybe_metadata_item.map_err(|error| {
466            anyhow::Error::msg(error.to_string()).context("Failed to decode metadata item")
467        })?;
468        debug!(?metadata_item, "Decoded metadata item");
469
470        let mut methods_metadata_decoder = metadata_item.into_decoder();
471        while let Some(method_metadata_decoder) = methods_metadata_decoder.decode_next() {
472            let (_, method_metadata_item) =
473                method_metadata_decoder.decode_next().map_err(|error| {
474                    anyhow::Error::msg(error.to_string())
475                        .context("Failed to decode method metadata")
476                })?;
477
478            trace!(?method_metadata_item, "Decoded method metadata item");
479
480            let method_name =
481                str::from_utf8(method_metadata_item.method_name).with_context(|| {
482                    format!(
483                        "Non-UTF-8 method name: {:?}",
484                        method_metadata_item.method_name
485                    )
486                })?;
487            let symbol = parsed_exports
488                .remove(method_name)
489                .with_context(|| anyhow::anyhow!("Method {method_name} not found in symbols"))?;
490
491            metadata_methods.push(symbol);
492        }
493    }
494
495    Ok(metadata_methods)
496}
497
498/// Convert RISC-V ELF `cdylib` into Abundance contract file format
499pub fn convert(input_file: &[u8]) -> anyhow::Result<Vec<u8>> {
500    let buffer = SharedAlignedBuffer::<Align16>::from_bytes(input_file);
501    let elf =
502        ElfFile64::<LittleEndian>::parse(buffer.as_slice()).context("Failed to parse ELF file")?;
503
504    if !is_correct_header(elf.elf_header()) {
505        return Err(anyhow::anyhow!(
506            "Invalid ELF header: {:?}",
507            elf.elf_header()
508        ));
509    }
510
511    check_relocations(&elf)?;
512    let ParsedSections {
513        metadata_section_offset,
514        metadata_section_size,
515        rodata_section_offset,
516        rodata_section_size,
517        metadata_rodata_padding,
518        ro_memory_size,
519        code_section_offset,
520        code_section_size,
521    } = parse_sections(&elf)?;
522
523    if metadata_section_size == 0 {
524        return Err(anyhow::anyhow!("Metadata not found"));
525    }
526
527    check_imports(&elf)?;
528
529    let mut parsed_exports = parse_exports(&elf)?;
530
531    let host_call_fn_offset = extract_host_call_fn_offset(input_file, &mut parsed_exports)?;
532
533    if host_call_fn_offset != 0 && host_call_fn_offset < code_section_offset {
534        return Err(anyhow::anyhow!(
535            "Host call function offset {host_call_fn_offset} is before `.text` section offset \
536            {code_section_offset}"
537        ));
538    }
539
540    let metadata_bytes = input_file
541        .get(metadata_section_offset as usize..)
542        .with_context(|| {
543            format!(
544                "Metadata offset {metadata_section_offset} out of range of input file ({} bytes)",
545                input_file.len()
546            )
547        })?
548        .get(..metadata_section_size as usize)
549        .with_context(|| format!("Metadata size {metadata_section_size} is invalid"))?;
550
551    let metadata_methods = parse_metadata_methods(&mut parsed_exports, metadata_bytes)?;
552
553    if !parsed_exports.is_empty() {
554        return Err(anyhow::anyhow!("Found unused exports: {parsed_exports:?}"));
555    }
556
557    let header_size = size_of::<ContractFileHeader>();
558    let methods_metadata_size = size_of::<ContractFileMethodMetadata>() * metadata_methods.len();
559    let header_with_methods_metadata_size = (header_size + methods_metadata_size) as u64;
560
561    let mut output_file = Vec::new();
562
563    // Write file header
564    let contract_file_header = ContractFileHeader {
565        magic: CONTRACT_FILE_MAGIC,
566        read_only_section_file_size: (metadata_section_size
567            + rodata_section_size
568            + metadata_rodata_padding)
569            .try_into()
570            .context("Read-only section size is over 32-bit")?,
571        read_only_section_memory_size: ro_memory_size
572            .try_into()
573            .context("Read-only section size is over 32-bit")?,
574        metadata_offset: {
575            let metadata_offset = if metadata_section_offset < rodata_section_offset {
576                header_with_methods_metadata_size
577            } else {
578                header_with_methods_metadata_size + rodata_section_size + metadata_rodata_padding
579            };
580
581            metadata_offset
582                .try_into()
583                .context("Metadata offset is over 32-bit")?
584        },
585        metadata_size: metadata_section_size
586            .try_into()
587            .context("Metadata size is over 16-bit")?,
588        num_methods: metadata_methods
589            .len()
590            .try_into()
591            .context("Number of methods is over 16-bit")?,
592        host_call_fn_offset: {
593            let host_call_fn_offset = if host_call_fn_offset == 0 {
594                0
595            } else {
596                header_with_methods_metadata_size
597                    + (metadata_section_size + rodata_section_size + metadata_rodata_padding)
598                    + (host_call_fn_offset - code_section_offset)
599            };
600
601            host_call_fn_offset
602                .try_into()
603                .context("Host call offset is over 32-bit")?
604        },
605    };
606    output_file.extend_from_slice(contract_file_header.as_bytes());
607
608    // Write metadata of each method
609    for metadata_method in metadata_methods {
610        let offset = header_with_methods_metadata_size
611            + (metadata_section_size + rodata_section_size + metadata_rodata_padding)
612            + (metadata_method.offset - code_section_offset);
613        let contract_file_function_metadata = ContractFileMethodMetadata {
614            offset: offset.try_into().context("Method offset is over 32-bit")?,
615            size: metadata_method
616                .size
617                .try_into()
618                .context("Method size is over 32-bit")?,
619        };
620        output_file.extend_from_slice(contract_file_function_metadata.as_bytes());
621    }
622
623    // Write `ab-contract-metadata` and `.rodata` sections with possible padding between them
624    if metadata_section_offset < rodata_section_offset {
625        output_file.extend_from_slice(
626            &input_file[metadata_section_offset as usize..][..metadata_section_size as usize],
627        );
628        output_file.extend(iter::repeat_n(0, metadata_rodata_padding as usize));
629        output_file.extend_from_slice(
630            &input_file[rodata_section_offset as usize..][..rodata_section_size as usize],
631        );
632    } else {
633        output_file.extend_from_slice(
634            &input_file[rodata_section_offset as usize..][..rodata_section_size as usize],
635        );
636        output_file.extend(iter::repeat_n(0, metadata_rodata_padding as usize));
637        output_file.extend_from_slice(
638            &input_file[metadata_section_offset as usize..][..metadata_section_size as usize],
639        );
640    }
641
642    // Write `.text` section
643    output_file.extend_from_slice(
644        &input_file[code_section_offset as usize..][..code_section_size as usize],
645    );
646
647    // TODO: Compress with zstd? If so, then read-only data can be expanded to the real size from
648    //  the very beginning, such that after decompression it'll already have correct layout.
649    Ok(output_file)
650}