Skip to main content

elfpak_core/
source.rs

1//! The source filesystem, abstracted behind `--root`.
2//!
3//! The source root is treated as strictly read-only, and as the logical `/` of
4//! the target system. Symlinks are followed *logically* (inside the root) so a
5//! sysroot can be analyzed without any chance of escaping to the host.
6
7use crate::{
8    elf::ElfMetadata,
9    error::{Error, Result, io},
10};
11use std::{
12    collections::HashMap,
13    path::{Component, Path, PathBuf},
14};
15
16/// How many symlinks may be traversed while resolving one logical path.
17///
18/// glibc's own limit is `SYMLOOP_MAX` (40 on Linux); matching it means a path
19/// that resolves here is a path the loader would also resolve.
20const SYMLINK_HOPS_MAX: usize = 40;
21
22/// Upper bound on the components still waiting to be walked. Each symlink hop
23/// can push the components of its target, so a pathological sysroot could grow
24/// this list without ever repeating a link; the bound turns that into an error
25/// rather than into memory growth.
26const PENDING_COMPONENTS_MAX: usize = 1024;
27
28/// A symlink observed while resolving a logical path.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct SymlinkEntry {
31    /// Logical location of the link itself, e.g. `/lib/x86_64-linux-gnu/libfoo.so.1`.
32    pub logical: PathBuf,
33    /// Raw link target, verbatim, so the relationship is preserved on output.
34    pub target: PathBuf,
35}
36
37/// A logical path resolved to a real file inside the source root.
38#[derive(Debug, Clone)]
39pub struct Resolved {
40    /// Logical path after following symlinks, e.g. `/usr/lib/.../libfoo.so.1.4.2`.
41    pub logical: PathBuf,
42    /// Host path of that file (source root prepended).
43    pub host: PathBuf,
44    /// Symlinks traversed on the way, in traversal order.
45    pub links: Vec<SymlinkEntry>,
46    pub kind: EntryKind,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum EntryKind {
51    File,
52    Directory,
53    Other,
54}
55
56#[derive(Debug, Clone)]
57pub struct SourceRoot {
58    path: PathBuf,
59}
60
61impl SourceRoot {
62    pub fn new(path: impl Into<PathBuf>) -> SourceRoot {
63        SourceRoot { path: path.into() }
64    }
65
66    pub fn path(&self) -> &Path {
67        &self.path
68    }
69
70    /// Map a logical path onto the host without following symlinks.
71    pub fn host_path(&self, logical: &Path) -> PathBuf {
72        crate::paths::join_under(&self.path, logical)
73    }
74
75    /// Resolve a logical path, following symlinks within the root.
76    ///
77    /// Returns `Ok(None)` when the path does not exist. Anything else — an
78    /// unreadable directory, a component that is not a directory — is an error,
79    /// because a caller reaching this way named the path. Use [`probe`] for a
80    /// candidate the caller only guessed at.
81    ///
82    /// [`probe`]: SourceRoot::probe
83    pub fn resolve(&self, logical: &Path) -> Result<Option<Resolved>> {
84        self.walk(logical, Absence::NotFoundOnly)
85    }
86
87    /// As [`resolve`], but every failure to stat a component answers "nothing
88    /// usable here".
89    ///
90    /// This is what a library lookup needs: glibc's `open_path` treats each
91    /// failed candidate the same way and moves on to the next directory, so a
92    /// stale search-path entry naming a regular file, or a directory this
93    /// process cannot enter, must not fail a build the loader would complete.
94    ///
95    /// [`resolve`]: SourceRoot::resolve
96    pub fn probe(&self, logical: &Path) -> Result<Option<Resolved>> {
97        self.walk(logical, Absence::AnyFailureToStat)
98    }
99
100    fn walk(&self, logical: &Path, absence: Absence) -> Result<Option<Resolved>> {
101        // Deliberately not normalized first: the kernel resolves `..` against
102        // what the preceding components actually resolved to, so collapsing it
103        // lexically would walk past a symlinked parent into a different
104        // directory. The walk below pops `current`, which is that behavior.
105        let mut pending = components_reversed(logical);
106        let mut current = PathBuf::from("/");
107        let mut links: Vec<SymlinkEntry> = Vec::new();
108        let mut hops = 0usize;
109
110        while let Some(component) = pending.pop() {
111            if component == ".." {
112                current.pop();
113                continue;
114            }
115            if component == "." {
116                continue;
117            }
118
119            let next_logical = current.join(&component);
120            let host = self.host_path(&next_logical);
121            let Some(metadata) = symlink_metadata_optional(&host, absence)? else {
122                return Ok(None);
123            };
124            if !metadata.is_symlink() {
125                current = next_logical;
126                continue;
127            }
128
129            // Each hop consumes a component and is counted, so a chain of links
130            // cannot walk forever.
131            if hops == SYMLINK_HOPS_MAX || pending.len() > PENDING_COMPONENTS_MAX {
132                return Err(Error::SymlinkLoop {
133                    path: logical.to_path_buf(),
134                });
135            }
136            hops += 1;
137
138            let target = std::fs::read_link(&host).map_err(|e| io(&host, e))?;
139            links.push(SymlinkEntry {
140                logical: next_logical,
141                target: target.clone(),
142            });
143            if target.is_absolute() {
144                current = PathBuf::from("/");
145            }
146            pending.extend(components_reversed(&target));
147        }
148
149        self.describe(current, links, absence)
150    }
151
152    /// Stat the destination a walk arrived at, without following any further.
153    fn describe(
154        &self,
155        logical: PathBuf,
156        links: Vec<SymlinkEntry>,
157        absence: Absence,
158    ) -> Result<Option<Resolved>> {
159        assert!(logical.is_absolute());
160
161        let host = self.host_path(&logical);
162        let Some(metadata) = metadata_optional(&host, absence)? else {
163            return Ok(None);
164        };
165        let kind = if metadata.is_dir() {
166            EntryKind::Directory
167        } else if metadata.is_file() {
168            EntryKind::File
169        } else {
170            EntryKind::Other
171        };
172        Ok(Some(Resolved {
173            logical,
174            host,
175            links,
176            kind,
177        }))
178    }
179
180    /// Read a file identified by a logical path.
181    pub fn read(&self, logical: &Path) -> Result<Option<Vec<u8>>> {
182        self.read_bounded(logical, usize::MAX)
183    }
184
185    /// As [`SourceRoot::read`], but reading at most `limit_bytes`.
186    ///
187    /// A file in the source filesystem is as large as that filesystem says, so
188    /// anything read to decide something small — a configuration file naming a
189    /// few directories — says how much of it it is willing to look at. Content
190    /// past the limit is truncated rather than being an error: the reader's
191    /// answer is a hint, and a partial one beats failing the build.
192    pub fn read_bounded(&self, logical: &Path, limit_bytes: usize) -> Result<Option<Vec<u8>>> {
193        use std::io::Read;
194
195        let Some(resolved) = self.resolve(logical)? else {
196            return Ok(None);
197        };
198        if resolved.kind != EntryKind::File {
199            return Ok(None);
200        }
201        let file = std::fs::File::open(&resolved.host).map_err(|e| io(&resolved.host, e))?;
202        let mut bytes = Vec::new();
203        file.take(limit_bytes as u64)
204            .read_to_end(&mut bytes)
205            .map_err(|e| io(&resolved.host, e))?;
206        Ok(Some(bytes))
207    }
208
209    pub fn exists(&self, logical: &Path) -> bool {
210        matches!(self.probe(logical), Ok(Some(_)))
211    }
212
213    pub fn is_dir(&self, logical: &Path) -> bool {
214        matches!(self.probe(logical), Ok(Some(r)) if r.kind == EntryKind::Directory)
215    }
216
217    /// Directory entries (names only), sorted for deterministic output.
218    pub fn read_dir(&self, logical: &Path) -> Result<Vec<std::ffi::OsString>> {
219        let host = match self.resolve(logical)? {
220            Some(resolved) if resolved.kind == EntryKind::Directory => resolved.host,
221            _ => return Ok(Vec::new()),
222        };
223        let mut names = Vec::new();
224        for entry in std::fs::read_dir(&host).map_err(|e| io(&host, e))? {
225            let entry = entry.map_err(|e| io(&host, e))?;
226            names.push(entry.file_name());
227        }
228        // Readdir order differs between filesystems; sorting is what makes two
229        // runs over the same tree produce the same bundle.
230        names.sort();
231        Ok(names)
232    }
233}
234
235/// Path components in pop order, i.e. reversed, with `..` kept as a component
236/// so that the walk resolves it against what it has already traversed.
237fn components_reversed(path: &Path) -> Vec<std::ffi::OsString> {
238    path.components()
239        .filter_map(|c| match c {
240            Component::Normal(part) => Some(part.to_os_string()),
241            Component::ParentDir => Some(std::ffi::OsString::from("..")),
242            Component::RootDir | Component::CurDir | Component::Prefix(_) => None,
243        })
244        .rev()
245        .collect()
246}
247
248/// Which stat failures a walk reports as "not there" rather than as an error.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250enum Absence {
251    /// Only a genuinely missing path. Anything else is worth telling the caller
252    /// about, because the caller named this path.
253    NotFoundOnly,
254    /// Any failure to stat. A lookup is asking "is it here?", and every answer
255    /// other than yes means try the next directory.
256    AnyFailureToStat,
257}
258
259impl Absence {
260    fn covers(self, error: &std::io::Error) -> bool {
261        use std::io::ErrorKind;
262
263        match self {
264            Absence::NotFoundOnly => error.kind() == ErrorKind::NotFound,
265            Absence::AnyFailureToStat => matches!(
266                error.kind(),
267                ErrorKind::NotFound
268                    | ErrorKind::NotADirectory
269                    | ErrorKind::PermissionDenied
270                    | ErrorKind::InvalidFilename
271            ),
272        }
273    }
274}
275
276/// `None` means there is nothing usable at the path. Any error `absence` does
277/// not cover is propagated.
278fn symlink_metadata_optional(host: &Path, absence: Absence) -> Result<Option<std::fs::Metadata>> {
279    match std::fs::symlink_metadata(host) {
280        Ok(metadata) => Ok(Some(metadata)),
281        Err(e) if absence.covers(&e) => Ok(None),
282        Err(e) => Err(io(host, e)),
283    }
284}
285
286/// As [`symlink_metadata_optional`], but following a final symlink.
287fn metadata_optional(host: &Path, absence: Absence) -> Result<Option<std::fs::Metadata>> {
288    match std::fs::metadata(host) {
289        Ok(metadata) => Ok(Some(metadata)),
290        Err(e) if absence.covers(&e) => Ok(None),
291        Err(e) => Err(io(host, e)),
292    }
293}
294
295/// Parses each ELF object at most once, keyed by host path.
296#[derive(Debug, Default)]
297pub struct ElfCache {
298    entries: HashMap<PathBuf, Option<ElfMetadata>>,
299}
300
301impl ElfCache {
302    pub fn new() -> ElfCache {
303        ElfCache::default()
304    }
305
306    /// Parse `host` as ELF. `Ok(None)` means the file exists but is not a usable
307    /// ELF object.
308    pub fn get(&mut self, host: &Path) -> Result<Option<ElfMetadata>> {
309        if let Some(cached) = self.entries.get(host) {
310            return Ok(cached.clone());
311        }
312        let parsed = match ElfMetadata::parse_file(host) {
313            Ok(metadata) => Some(metadata),
314            Err(Error::NotElf { .. }) | Err(Error::Elf { .. }) => None,
315            Err(e) => return Err(e),
316        };
317        self.entries.insert(host.to_path_buf(), parsed.clone());
318        Ok(parsed)
319    }
320
321    /// Like [`ElfCache::get`], but a parse failure is an error rather than `None`.
322    pub fn require(&mut self, host: &Path) -> Result<ElfMetadata> {
323        match self.get(host)? {
324            Some(metadata) => Ok(metadata),
325            None => ElfMetadata::parse_file(host),
326        }
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn sysroot() -> (tempfile::TempDir, SourceRoot) {
335        let temp = tempfile::tempdir().expect("tempdir");
336        let root = SourceRoot::new(temp.path());
337        (temp, root)
338    }
339
340    /// The kernel applies `..` to what the preceding component resolved to, so
341    /// `link/../lib` depends on where `link` points. Collapsing it lexically
342    /// would look in the link's own parent instead.
343    #[test]
344    fn parent_components_are_applied_after_symlinks() {
345        let (temp, root) = sysroot();
346        std::fs::create_dir_all(temp.path().join("real/sub")).unwrap();
347        std::fs::create_dir_all(temp.path().join("real/lib")).unwrap();
348        std::fs::create_dir_all(temp.path().join("lib")).unwrap();
349        std::fs::write(temp.path().join("real/lib/libbase.so.1"), b"right").unwrap();
350        std::fs::write(temp.path().join("lib/libbase.so.1"), b"wrong").unwrap();
351        std::os::unix::fs::symlink("real/sub", temp.path().join("link")).unwrap();
352
353        let resolved = root
354            .resolve(Path::new("/link/../lib/libbase.so.1"))
355            .unwrap()
356            .expect("resolves through the symlink");
357        assert_eq!(resolved.logical, Path::new("/real/lib/libbase.so.1"));
358        assert_eq!(std::fs::read(&resolved.host).unwrap(), b"right");
359    }
360
361    /// A stale search-path entry naming a regular file is `ENOTDIR`, which the
362    /// loader treats as "not here" and walks past.
363    #[test]
364    fn a_non_directory_component_is_absent_rather_than_an_error() {
365        let (temp, root) = sysroot();
366        std::fs::write(temp.path().join("notadir"), b"file").unwrap();
367
368        assert!(
369            root.probe(Path::new("/notadir/libbase.so.1"))
370                .unwrap()
371                .is_none()
372        );
373        assert!(!root.exists(Path::new("/notadir/libbase.so.1")));
374        // A path the caller named keeps its real error.
375        let error = root
376            .resolve(Path::new("/notadir/libbase.so.1"))
377            .expect_err("a named path reports why it could not be read");
378        assert_eq!(error.code(), "E1000");
379    }
380
381    #[test]
382    fn a_symlink_chain_longer_than_the_loader_allows_is_an_error() {
383        let (temp, root) = sysroot();
384        for hop in 0..=SYMLINK_HOPS_MAX {
385            std::os::unix::fs::symlink(
386                format!("link{}", hop + 1),
387                temp.path().join(format!("link{hop}")),
388            )
389            .unwrap();
390        }
391
392        let error = root.resolve(Path::new("/link0")).unwrap_err();
393        assert_eq!(error.code(), "E3003");
394    }
395}