Skip to main content

ferrum_native_ops/
coff.rs

1//! Shared, host-independent inspection of MSVC static operator libraries.
2//! Archive indexes and undefined import symbols are never export evidence.
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use object::read::archive::{ArchiveFile, ArchiveKind};
7use object::read::coff::{CoffBigFile, CoffFile, CoffHeader, ImageSymbol};
8use object::{FileKind, Object, ObjectSection, ObjectSymbol, SectionFlags, SymbolFlags};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct NativeOperatorObjectIdentity {
14    pub format: NativeOperatorObjectFormat,
15    pub class_bits: u8,
16    pub endianness: NativeOperatorObjectEndianness,
17    pub machine: u32,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum NativeOperatorObjectFormat {
23    Elf,
24    MachO,
25    Coff,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum NativeOperatorObjectEndianness {
31    Little,
32    Big,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct NativeOperatorObjectInspection {
37    pub identity: NativeOperatorObjectIdentity,
38    pub defined_symbols: Vec<String>,
39    pub strong_defined_symbols: Vec<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct NativeOperatorArchiveMember {
44    pub name: String,
45    pub bytes: Vec<u8>,
46    pub sha256: String,
47    pub object: NativeOperatorObjectInspection,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct NativeOperatorArchiveInspection {
52    pub identity: NativeOperatorObjectIdentity,
53    pub members: Vec<NativeOperatorArchiveMember>,
54    pub defined_symbols: Vec<String>,
55    pub strong_defined_symbols: Vec<String>,
56    pub symbol_definition_counts: BTreeMap<String, usize>,
57    pub indexed_symbol_members: BTreeMap<String, Vec<String>>,
58}
59
60impl NativeOperatorArchiveInspection {
61    /// Even coalescible COMDATs cannot provide two competing ABI entrypoints.
62    pub fn require_unique_exports(&self, exports: &[String]) -> Result<(), String> {
63        for symbol in exports {
64            if self.symbol_definition_counts.get(symbol).copied() != Some(1) {
65                return Err(format!(
66                    "MSVC archive must define required export exactly once: {symbol}"
67                ));
68            }
69            let defining_member = self
70                .members
71                .iter()
72                .find(|member| member.object.defined_symbols.contains(symbol))
73                .expect("definition count checked");
74            if !self
75                .indexed_symbol_members
76                .get(symbol)
77                .is_some_and(|members| members.len() == 1 && members[0] == defining_member.name)
78            {
79                return Err(format!("MSVC linker index does not resolve required export to its real definition: {symbol}"));
80            }
81        }
82        Ok(())
83    }
84}
85
86pub fn inspect_msvc_object(
87    bytes: &[u8],
88    expected_target: &str,
89) -> Result<NativeOperatorObjectInspection, String> {
90    let host = ferrum_types::NativeOperatorHostAbi::for_target(expected_target)?;
91    if host.compiler_flavor != ferrum_types::NativeOperatorCompilerFlavor::Msvc {
92        return Err("COFF inspection requires an explicit supported MSVC target".into());
93    }
94    match FileKind::parse(bytes).map_err(|error| format!("invalid COFF object: {error}"))? {
95        FileKind::Coff => {
96            // The COFF reader also exposes fields used by images; only plain
97            // relocatable objects belong in our static source-built archives.
98            if bytes.get(16..18) != Some(&[0, 0][..]) {
99                return Err("COFF object has an image optional header".into());
100            }
101            let file: CoffFile<'_> = CoffFile::parse(bytes).map_err(|error| error.to_string())?;
102            inspect_coff(file)
103        }
104        FileKind::CoffBig => {
105            inspect_coff(CoffBigFile::parse(bytes).map_err(|error| error.to_string())?)
106        }
107        FileKind::CoffImport => {
108            Err("short COFF import library member is not a native implementation".into())
109        }
110        other => Err(format!(
111            "MSVC native implementation must be COFF/bigobj, got {other:?}"
112        )),
113    }
114}
115
116fn inspect_coff<'data, C: CoffHeader>(
117    file: CoffFile<'data, &'data [u8], C>,
118) -> Result<NativeOperatorObjectInspection, String> {
119    let header = file.coff_header();
120    if header.machine() != object::pe::IMAGE_FILE_MACHINE_AMD64 {
121        return Err(format!(
122            "COFF machine does not match x86_64-pc-windows-msvc: {:#x}",
123            header.machine()
124        ));
125    }
126    if header.number_of_sections() == 0 {
127        return Err("COFF implementation has no sections".into());
128    }
129    if header.characteristics()
130        & (object::pe::IMAGE_FILE_EXECUTABLE_IMAGE | object::pe::IMAGE_FILE_DLL)
131        != 0
132    {
133        return Err("COFF native member is marked as an executable image".into());
134    }
135    let mut comdat_sections = BTreeSet::new();
136    for section in file.sections() {
137        let name = section.name().map_err(|error| error.to_string())?;
138        let data = section.data().map_err(|error| error.to_string())?;
139        // Long-format import archives consist of ordinary COFF objects. Their
140        // .idata sections are authoritative import structure, even when the
141        // Object::imports() convenience API returns an empty list.
142        if name == ".idata" || name.starts_with(".idata$") {
143            return Err(format!("long COFF import library member contains {name}"));
144        }
145        if name == ".drectve" {
146            validate_runtime_directives(data)?;
147        }
148        if matches!(section.flags(), SectionFlags::Coff { characteristics }
149            if characteristics & object::pe::IMAGE_SCN_LNK_COMDAT != 0)
150        {
151            comdat_sections.insert(section.index().0);
152        }
153        section
154            .coff_relocations()
155            .map_err(|error| error.to_string())?;
156    }
157    let mut selections = BTreeMap::new();
158    let mut primary_symbols = BTreeSet::new();
159    for symbol in file.symbols() {
160        let raw = symbol.coff_symbol();
161        if symbol.index().0 + 1 + usize::from(raw.number_of_aux_symbols())
162            > header.number_of_symbols() as usize
163        {
164            return Err("COFF symbol has a truncated auxiliary record".into());
165        }
166        primary_symbols.insert(symbol.index().0);
167        let name = symbol.name().map_err(|error| error.to_string())?;
168        if name.starts_with("__IMPORT_DESCRIPTOR_")
169            || name == "__NULL_IMPORT_DESCRIPTOR"
170            || name.ends_with("_NULL_THUNK_DATA")
171        {
172            return Err(format!("long COFF import library marker: {name}"));
173        }
174        if let Some(index) = symbol.section_index() {
175            file.section_by_index(index)
176                .map_err(|error| error.to_string())?;
177            if let SymbolFlags::CoffSection {
178                selection,
179                associative_section,
180            } = symbol.flags()
181            {
182                if comdat_sections.contains(&index.0) {
183                    if !(1..=7).contains(&selection)
184                        || selections.insert(index.0, selection).is_some()
185                    {
186                        return Err("invalid or duplicate COFF COMDAT selection".into());
187                    }
188                    if selection == object::pe::IMAGE_COMDAT_SELECT_ASSOCIATIVE {
189                        let associated =
190                            associative_section.ok_or("associative COMDAT has no parent")?;
191                        if associated == index || !comdat_sections.contains(&associated.0) {
192                            return Err("associative COMDAT has an invalid parent".into());
193                        }
194                    }
195                }
196            }
197        }
198    }
199    if comdat_sections
200        .iter()
201        .any(|index| !selections.contains_key(index))
202    {
203        return Err("COFF COMDAT section has no valid selection record".into());
204    }
205    for section in file.sections() {
206        for relocation in section
207            .coff_relocations()
208            .map_err(|error| error.to_string())?
209        {
210            if relocation.typ.get(object::LittleEndian) != 0
211                && !primary_symbols
212                    .contains(&(relocation.symbol_table_index.get(object::LittleEndian) as usize))
213            {
214                return Err("COFF relocation does not reference a real symbol".into());
215            }
216        }
217    }
218    let mut defined = BTreeSet::new();
219    let mut strong = BTreeSet::new();
220    for symbol in file.symbols() {
221        if !symbol.is_global() || !symbol.is_definition() {
222            continue;
223        }
224        let name = symbol.name().map_err(|error| error.to_string())?;
225        if name.is_empty() || !defined.insert(name.to_owned()) {
226            return Err(format!("empty or repeated COFF definition: {name}"));
227        }
228        let selection = symbol
229            .section_index()
230            .and_then(|index| selections.get(&index.0))
231            .copied();
232        if !symbol.is_weak() && matches!(selection, None | Some(1)) {
233            strong.insert(name.to_owned());
234        }
235    }
236    Ok(NativeOperatorObjectInspection {
237        identity: NativeOperatorObjectIdentity {
238            format: NativeOperatorObjectFormat::Coff,
239            class_bits: 64,
240            endianness: NativeOperatorObjectEndianness::Little,
241            machine: u32::from(header.machine()),
242        },
243        defined_symbols: defined.into_iter().collect(),
244        strong_defined_symbols: strong.into_iter().collect(),
245    })
246}
247
248fn validate_runtime_directives(bytes: &[u8]) -> Result<(), String> {
249    let text = std::str::from_utf8(bytes).map_err(|_| "COFF linker directives are not UTF-8")?;
250    let mut tokens = Vec::new();
251    let mut token = String::new();
252    let mut quoted = false;
253    for ch in text.chars() {
254        if ch == '"' {
255            quoted = !quoted;
256        } else if (ch.is_ascii_whitespace() || ch == '\0') && !quoted {
257            if !token.is_empty() {
258                tokens.push(std::mem::take(&mut token));
259            }
260        } else {
261            token.push(ch);
262        }
263    }
264    if quoted {
265        return Err("COFF linker directive contains an unterminated quote".into());
266    }
267    if !token.is_empty() {
268        tokens.push(token);
269    }
270    for token in tokens {
271        let token = token.to_ascii_lowercase();
272        if let Some(library) = token
273            .strip_prefix("/defaultlib:")
274            .or_else(|| token.strip_prefix("-defaultlib:"))
275        {
276            let library = library.rsplit(['/', '\\']).next().unwrap_or(library);
277            let library = library.strip_suffix(".lib").unwrap_or(library);
278            if matches!(
279                library,
280                "libcmt"
281                    | "libcmtd"
282                    | "libcpmt"
283                    | "libcpmtd"
284                    | "msvcrtd"
285                    | "msvcprtd"
286                    | "vcruntimed"
287                    | "ucrtd"
288                    | "libvcruntime"
289                    | "libvcruntimed"
290                    | "libucrt"
291                    | "libucrtd"
292            ) {
293                return Err(format!(
294                    "COFF runtime directive conflicts with release /MD: {token}"
295                ));
296            }
297        }
298        if let Some(mismatch) = token
299            .strip_prefix("/failifmismatch:")
300            .or_else(|| token.strip_prefix("-failifmismatch:"))
301        {
302            if let Some(runtime) = mismatch.strip_prefix("runtimelibrary=") {
303                if runtime != "md_dynamicrelease" {
304                    return Err(format!(
305                        "COFF RuntimeLibrary conflicts with release /MD: {token}"
306                    ));
307                }
308            }
309        }
310    }
311    Ok(())
312}
313
314pub fn inspect_msvc_archive(
315    bytes: &[u8],
316    expected_target: &str,
317) -> Result<NativeOperatorArchiveInspection, String> {
318    let archive =
319        ArchiveFile::parse(bytes).map_err(|error| format!("invalid MSVC archive: {error}"))?;
320    if archive.is_thin() || !bytes.starts_with(b"!<arch>\n") {
321        return Err("MSVC native archive must be self-contained, not thin".into());
322    }
323    if archive.kind() != ArchiveKind::Coff {
324        return Err("MSVC library is missing its two COFF linker members".into());
325    }
326    let mut members = Vec::new();
327    let mut names = BTreeSet::new();
328    let mut counts = BTreeMap::new();
329    let mut strong = BTreeSet::new();
330    let mut offsets = BTreeMap::new();
331    for member in archive.members() {
332        let member = member.map_err(|error| error.to_string())?;
333        let name =
334            std::str::from_utf8(member.name()).map_err(|_| "archive member name is not UTF-8")?;
335        if name.is_empty() || name.contains('\0') || !names.insert(name.to_owned()) {
336            return Err(format!("empty or duplicate MSVC archive member: {name}"));
337        }
338        let data = member.data(bytes).map_err(|error| error.to_string())?;
339        let offset = member
340            .file_range()
341            .0
342            .checked_sub(60)
343            .ok_or("invalid COFF member offset")?;
344        offsets.insert(offset, name.to_owned());
345        let object = inspect_msvc_object(data, expected_target)
346            .map_err(|error| format!("{name}: {error}"))?;
347        for symbol in &object.defined_symbols {
348            *counts.entry(symbol.clone()).or_insert(0usize) += 1;
349        }
350        for symbol in &object.strong_defined_symbols {
351            if !strong.insert(symbol.clone()) {
352                return Err(format!(
353                    "duplicate strong MSVC archive definition: {symbol}"
354                ));
355            }
356        }
357        members.push(NativeOperatorArchiveMember {
358            name: name.to_owned(),
359            bytes: data.to_vec(),
360            sha256: format!("{:x}", Sha256::digest(data)),
361            object,
362        });
363    }
364    let identity = members
365        .first()
366        .ok_or("MSVC native archive has no object members")?
367        .object
368        .identity
369        .clone();
370    let mut indexed_symbol_members: BTreeMap<String, Vec<String>> = BTreeMap::new();
371    for symbol in archive
372        .symbols()
373        .map_err(|error| error.to_string())?
374        .ok_or("MSVC archive has no linker symbol table")?
375    {
376        let symbol = symbol.map_err(|error| error.to_string())?;
377        let name =
378            std::str::from_utf8(symbol.name()).map_err(|_| "COFF linker symbol is not UTF-8")?;
379        if name.is_empty() {
380            return Err("COFF linker symbol is empty".into());
381        }
382        let member = offsets
383            .get(&symbol.offset().0)
384            .ok_or("COFF linker symbol points outside object members")?;
385        indexed_symbol_members
386            .entry(name.to_owned())
387            .or_default()
388            .push(member.clone());
389    }
390    Ok(NativeOperatorArchiveInspection {
391        identity,
392        members,
393        defined_symbols: counts.keys().cloned().collect(),
394        strong_defined_symbols: strong.into_iter().collect(),
395        symbol_definition_counts: counts,
396        indexed_symbol_members,
397    })
398}
399
400#[cfg(test)]
401#[path = "coff_tests.rs"]
402pub(crate) mod tests;