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