Skip to main content

codehelion_artifact/
archive.rs

1//! Static archive implementation of the codehelion artifact backend boundary.
2//!
3//! An archive is not treated as one executable byte stream. Each local object
4//! member is parsed by its format backend, then flattened only for common
5//! metrics while retaining member provenance in [`ArtifactIr::archive_members`].
6
7use std::collections::BTreeMap;
8
9use crate::elf::ElfBackend;
10use crate::macho::MachOBackend;
11use crate::pe::PeCoffBackend;
12use crate::wasm::WasmBackend;
13use crate::{
14    ArtifactArchiveMember, ArtifactBackend, ArtifactCapabilities, ArtifactError,
15    ArtifactFingerprint, ArtifactFormat, ArtifactIr, detect_format,
16};
17use object::read::archive::ArchiveFile;
18
19/// Parser backend for static archives containing locally embedded object files.
20#[derive(Debug, Default, Clone, Copy)]
21pub struct ArchiveBackend;
22
23impl ArtifactBackend for ArchiveBackend {
24    fn format(&self) -> ArtifactFormat {
25        ArtifactFormat::Archive
26    }
27
28    fn detects(&self, bytes: &[u8]) -> bool {
29        bytes.starts_with(b"!<arch>\n") || bytes.starts_with(b"!<thin>\n")
30    }
31
32    fn parse(&self, bytes: &[u8]) -> Result<ArtifactIr, ArtifactError> {
33        let archive = ArchiveFile::parse(bytes).map_err(|error| malformed(error.to_string()))?;
34        let mut ir = ArtifactIr::empty(ArtifactFormat::Archive, bytes);
35        for member in archive.members() {
36            let member = match member {
37                Ok(member) => member,
38                Err(error) => {
39                    ir.archive_members.push(incomplete_member(
40                        "<truncated archive member>".to_owned(),
41                        u64::try_from(bytes.len()).unwrap_or(u64::MAX),
42                        0,
43                        &error.to_string(),
44                    ));
45                    break;
46                }
47            };
48            let name = String::from_utf8_lossy(member.name()).into_owned();
49            let (offset, size) = member.file_range();
50            let thin = member.is_thin();
51            let data = match member.data(bytes) {
52                Ok(data) => data,
53                Err(error) => {
54                    ir.archive_members.push(incomplete_member(
55                        name,
56                        offset,
57                        size,
58                        &error.to_string(),
59                    ));
60                    continue;
61                }
62            };
63            let fingerprint = if thin {
64                // Thin members deliberately have no local payload. Their
65                // declared member paths are still distinct local evidence,
66                // unlike hashing the same empty byte slice for every member.
67                ArtifactFingerprint::from_content("thin-archive-member", name.as_bytes())
68            } else {
69                ArtifactFingerprint::from_content("archive-member", data)
70            };
71            let format = detect_format(data);
72            let mut provenance = ArtifactArchiveMember {
73                name,
74                fingerprint,
75                offset,
76                size,
77                format,
78                thin,
79                parse_error: None,
80            };
81            if thin {
82                provenance.parse_error = Some(
83                    "thin archive member has no local bytes; external paths are never followed"
84                        .to_owned(),
85                );
86            } else if let Some(format) = format {
87                match parse_member(format, data) {
88                    Ok(member_ir) => merge_member(&mut ir, member_ir, &provenance),
89                    Err(error) => provenance.parse_error = Some(error.to_string()),
90                }
91            } else {
92                provenance.parse_error = Some("member format is not supported".to_owned());
93            }
94            ir.archive_members.push(provenance);
95        }
96        ir.capabilities = ArtifactCapabilities {
97            symbols: !ir.symbols.is_empty(),
98            call_graph: !ir.calls.is_empty(),
99            source_mapping: !ir.source_mappings.is_empty(),
100            debug_info_unreadable: ir.capabilities.debug_info_unreadable,
101            normalized_duplicates: !ir.symbols.is_empty()
102                && ir.symbols.iter().all(|symbol| symbol.normalized.is_some()),
103            independent_data_segments: false,
104            relocations: !ir.relocations.is_empty(),
105            data_segments: !ir.data_segments.is_empty(),
106        };
107        Ok(ir)
108    }
109
110    fn capabilities(&self) -> ArtifactCapabilities {
111        ArtifactCapabilities {
112            symbols: true,
113            call_graph: true,
114            source_mapping: true,
115            debug_info_unreadable: false,
116            normalized_duplicates: false,
117            independent_data_segments: false,
118            relocations: true,
119            data_segments: true,
120        }
121    }
122}
123
124/// Record an unreadable archive member without discarding earlier members.
125fn incomplete_member(name: String, offset: u64, size: u64, error: &str) -> ArtifactArchiveMember {
126    let identity = format!("{name}:{offset}:{size}");
127    ArtifactArchiveMember {
128        name,
129        fingerprint: ArtifactFingerprint::from_content(
130            "archive-incomplete-member",
131            identity.as_bytes(),
132        ),
133        offset,
134        size,
135        format: None,
136        thin: false,
137        parse_error: Some(format!("truncated or unreadable archive member: {error}")),
138    }
139}
140
141fn parse_member(format: ArtifactFormat, bytes: &[u8]) -> Result<ArtifactIr, ArtifactError> {
142    match format {
143        ArtifactFormat::Wasm => WasmBackend.parse(bytes),
144        ArtifactFormat::Elf => ElfBackend.parse(bytes),
145        ArtifactFormat::MachO => MachOBackend.parse(bytes),
146        ArtifactFormat::PeCoff => PeCoffBackend.parse(bytes),
147        ArtifactFormat::Archive => Err(ArtifactError::Unsupported {
148            format: ArtifactFormat::Archive,
149        }),
150    }
151}
152
153/// Merge a parsed local object without letting its local offsets act as IDs.
154fn merge_member(archive: &mut ArtifactIr, member: ArtifactIr, provenance: &ArtifactArchiveMember) {
155    archive.capabilities.debug_info_unreadable |= member.capabilities.debug_info_unreadable;
156    let prefix = format!("{}:", provenance.name);
157    archive
158        .sections
159        .extend(member.sections.into_iter().map(|mut section| {
160            section.name = Some(format!("{prefix}{}", section.name.unwrap_or_default()));
161            section.offset = provenance.offset.saturating_add(section.offset);
162            section
163        }));
164    archive.imports.extend(member.imports);
165    let mut fingerprints = BTreeMap::new();
166    for mut symbol in member.symbols {
167        let original = symbol.fingerprint;
168        let fingerprint =
169            archive_member_fingerprint("archive-symbol", provenance.fingerprint, original);
170        fingerprints.insert(original, fingerprint);
171        symbol.fingerprint = fingerprint;
172        symbol.section = None;
173        symbol.offset = provenance.offset.saturating_add(symbol.offset);
174        archive.symbols.push(symbol);
175    }
176    archive.entry_points.extend(
177        member
178            .entry_points
179            .into_iter()
180            .filter_map(|value| fingerprints.get(&value).copied()),
181    );
182    archive.indirect_references.extend(
183        member
184            .indirect_references
185            .into_iter()
186            .filter_map(|value| fingerprints.get(&value).copied()),
187    );
188    archive
189        .calls
190        .extend(member.calls.into_iter().filter_map(|mut call| {
191            let caller = fingerprints.get(&call.caller).copied()?;
192            call.caller = caller;
193            call.target = call
194                .target
195                .and_then(|target| fingerprints.get(&target).copied());
196            Some(call)
197        }));
198    archive
199        .relocations
200        .extend(member.relocations.into_iter().map(|mut relocation| {
201            relocation.section = None;
202            relocation.offset = provenance.offset.saturating_add(relocation.offset);
203            relocation
204        }));
205    archive.source_mappings.extend(member.source_mappings);
206    archive
207        .source_mappings
208        .sort_by(|left, right| left.uri.cmp(&right.uri));
209    archive.source_mappings.dedup();
210    archive
211        .data_segments
212        .extend(member.data_segments.into_iter().map(|mut data| {
213            data.fingerprint = archive_member_fingerprint(
214                "archive-data",
215                provenance.fingerprint,
216                data.fingerprint,
217            );
218            data.section = None;
219            data.offset = provenance.offset.saturating_add(data.offset);
220            data
221        }));
222    archive.entry_points.sort();
223    archive.entry_points.dedup();
224    archive.indirect_references.sort();
225    archive.indirect_references.dedup();
226}
227
228fn archive_member_fingerprint(
229    domain: &str,
230    member: ArtifactFingerprint,
231    child: ArtifactFingerprint,
232) -> ArtifactFingerprint {
233    let mut bytes = Vec::with_capacity(32);
234    bytes.extend(member.as_bytes());
235    bytes.extend(child.as_bytes());
236    ArtifactFingerprint::from_content(domain, &bytes)
237}
238
239const fn malformed(message: String) -> ArtifactError {
240    ArtifactError::Malformed {
241        format: ArtifactFormat::Archive,
242        message,
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    #![allow(clippy::expect_used, clippy::unwrap_used)]
249
250    use super::*;
251    use object::write::{Object as WriteObject, StandardSection, Symbol, SymbolSection};
252    use object::{Architecture, BinaryFormat, Endianness, SymbolFlags, SymbolKind, SymbolScope};
253    use proptest::prelude::*;
254
255    fn coff_member(name: &[u8]) -> Vec<u8> {
256        let mut object =
257            WriteObject::new(BinaryFormat::Coff, Architecture::X86_64, Endianness::Little);
258        let text = object.section_id(StandardSection::Text);
259        let offset = object.append_section_data(text, &[0x90, 0xc3], 1);
260        object.add_symbol(Symbol {
261            name: name.to_vec(),
262            value: offset,
263            size: 2,
264            kind: SymbolKind::Text,
265            scope: SymbolScope::Dynamic,
266            weak: false,
267            section: SymbolSection::Section(text),
268            flags: SymbolFlags::None,
269        });
270        object.write().expect("write COFF member")
271    }
272
273    fn archive_member(name: &str, bytes: &[u8]) -> Vec<u8> {
274        let mut member = Vec::new();
275        let name = format!("{name}/");
276        member.extend(format!("{name:<16}").as_bytes());
277        member.extend(b"0           0     0     100644  ");
278        member.extend(format!("{:<10}", bytes.len()).as_bytes());
279        member.extend(b"`\n");
280        member.extend(bytes);
281        if bytes.len() % 2 != 0 {
282            member.push(b'\n');
283        }
284        member
285    }
286
287    fn archive_fixture() -> Vec<u8> {
288        let first = coff_member(b"left");
289        let second = coff_member(b"right");
290        let mut archive = b"!<arch>\n".to_vec();
291        archive.extend(archive_member("left.obj", &first));
292        archive.extend(archive_member("right.obj", &second));
293        archive
294    }
295
296    fn thin_archive_fixture() -> Vec<u8> {
297        let mut archive = b"!<thin>\n".to_vec();
298        archive.extend(archive_member("left.obj", b""));
299        archive.extend(archive_member("right.obj", b""));
300        archive
301    }
302
303    #[test]
304    fn delegates_local_coff_members_without_executing_them() {
305        let ir = ArchiveBackend
306            .parse(&archive_fixture())
307            .expect("parse archive fixture");
308        assert_eq!(ir.format, ArtifactFormat::Archive);
309        assert_eq!(ir.archive_members.len(), 2, "{ir:#?}");
310        assert!(
311            ir.archive_members
312                .iter()
313                .all(|member| member.parse_error.is_none())
314        );
315        assert_eq!(ir.symbols.len(), 2, "{ir:#?}");
316        assert_ne!(ir.symbols[0].fingerprint, ir.symbols[1].fingerprint);
317        assert!(ir.capabilities.symbols);
318    }
319
320    #[test]
321    fn a_truncated_later_member_keeps_the_earlier_members_available() {
322        let mut bytes = archive_fixture();
323        bytes.truncate(bytes.len().saturating_sub(1));
324
325        let ir = ArchiveBackend
326            .parse(&bytes)
327            .expect("a readable archive prefix remains useful");
328        assert!(
329            ir.archive_members
330                .iter()
331                .any(|member| member.name == "left.obj" && member.parse_error.is_none()),
332            "the complete leading member remains available: {:#?}",
333            ir.archive_members
334        );
335        assert!(
336            ir.archive_members.iter().any(|member| {
337                member
338                    .parse_error
339                    .as_deref()
340                    .is_some_and(|error| error.contains("truncated or unreadable"))
341            }),
342            "the unreadable tail is accounted for: {:#?}",
343            ir.archive_members
344        );
345    }
346
347    #[test]
348    fn thin_members_keep_distinct_path_based_fingerprints() {
349        let ir = ArchiveBackend
350            .parse(&thin_archive_fixture())
351            .expect("parse thin archive manifest");
352        assert_eq!(ir.archive_members.len(), 2, "{ir:#?}");
353        assert!(ir.archive_members.iter().all(|member| member.thin));
354        assert_ne!(
355            ir.archive_members[0].fingerprint,
356            ir.archive_members[1].fingerprint
357        );
358    }
359
360    #[test]
361    fn other_bytes_do_not_claim_the_backend() {
362        assert!(!ArchiveBackend.detects(b"not an archive"));
363        assert!(matches!(
364            ArchiveBackend.parse(b"not an archive"),
365            Err(ArtifactError::Malformed { .. })
366        ));
367    }
368
369    proptest::proptest! {
370        #[test]
371        fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
372            let _ = ArchiveBackend.parse(&bytes);
373        }
374    }
375}