libc-abi-tools 0.3.1

Rust tools for libc ABI metadata, starting with glibc
Documentation
//! Cross-release public ABI evidence. Snapshot absence is not historical absence.
use crate::{
    glibc::{self, Kind, Snapshot},
    version::Version,
};
use std::{
    collections::{BTreeMap, BTreeSet},
    fmt::Write,
    fs,
    path::Path,
};

const MAX_RELEASES: usize = 64;
const MAX_ROWS: usize = 100_000;
const MAX_CELLS: usize = 2_000_000;
const MAX_NAME_BYTES: usize = 16 * 1024 * 1024;
type Key = (String, String, Version);

/// Whether a link against one release should accept a versioned symbol
/// ([`Matrix::expect`]).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Expectation {
    /// The release's library exports the symbol at that version, with this kind.
    Accept(Kind),
    /// The release is in the matrix and its library does not export it.
    Reject,
    /// The release or library is not in the matrix, so nothing is known.
    Unknown,
}
impl Expectation {
    /// `accept`, `reject` or `unknown`, as `libc-abi glibc expect` prints it.
    pub fn label(self) -> &'static str {
        match self {
            Self::Accept(_) => "accept",
            Self::Reject => "reject",
            Self::Unknown => "unknown",
        }
    }
}

/// Every versioned symbol of several glibc releases' snapshots, with the kind
/// each release defines it as. Built from complete [`Snapshot`]s only.
#[derive(Debug, Default)]
pub struct Matrix {
    releases: BTreeSet<Version>,
    rows: BTreeMap<Key, BTreeMap<Version, Kind>>,
    name_bytes: usize,
}

fn cell(value: Option<Kind>) -> String {
    match value {
        Some(Kind::Function) => "F".into(),
        Some(Kind::Object(n)) => format!("D:{n}"),
        Some(Kind::Tls(n)) => format!("T:{n}"),
        None => "absent".into(),
    }
}

impl Matrix {
    /// Loads every release directory under `root` (the data-directory layout of
    /// [`Snapshot::load`]); entries not named like a release are skipped.
    ///
    /// # Errors
    ///
    /// Fails if `root` cannot be read, a release directory is not a canonical
    /// release from 2.17, there are none or more than 64, a snapshot fails to load,
    /// or the matrix exceeds its size limits.
    pub fn load(root: &Path) -> Result<Self, String> {
        let mut releases = BTreeSet::new();
        for entry in fs::read_dir(root).map_err(|e| e.to_string())? {
            let entry = entry.map_err(|e| e.to_string())?;
            if !entry.file_type().map_err(|e| e.to_string())?.is_dir() {
                continue;
            }
            let name = entry.file_name();
            let text = name.to_str().ok_or("release directory must be UTF-8")?;
            if !text.starts_with(|c: char| c.is_ascii_digit()) {
                continue;
            }
            let release: Version = text.parse()?;
            release.validate_release()?;
            if text != release.to_string() {
                return Err(format!("noncanonical release directory {text:?}"));
            }
            releases.insert(release);
            if releases.len() > MAX_RELEASES {
                return Err("matrix exceeds 64 snapshot releases".into());
            }
        }
        if releases.is_empty() {
            return Err("matrix has no release snapshots".into());
        }
        let mut matrix = Self::default();
        for release in releases {
            matrix.insert(Snapshot::load(root, release)?)?;
        }
        Ok(matrix)
    }

    /// Every release in the bundled glibc-abilists data.
    ///
    /// # Errors
    ///
    /// Fails if the bundled data is malformed.
    #[cfg(feature = "bundled-glibc-data")]
    pub fn bundled() -> Result<Self, String> {
        let mut matrix = Self::default();
        for release in glibc::bundled_releases()? {
            matrix.insert(Snapshot::bundled(release)?)?;
        }
        Ok(matrix)
    }

    /// Builds a matrix from snapshots already loaded.
    ///
    /// # Errors
    ///
    /// Fails if there are none, a release repeats, a snapshot lacks one of the
    /// eight tracked libraries or has an invalid or future-versioned symbol, or the
    /// matrix exceeds its size limits.
    pub fn from_snapshots(snapshots: impl IntoIterator<Item = Snapshot>) -> Result<Self, String> {
        let mut matrix = Self::default();
        for snapshot in snapshots {
            matrix.insert(snapshot)?;
        }
        if matrix.releases.is_empty() {
            return Err("matrix has no release snapshots".into());
        }
        Ok(matrix)
    }

    fn insert(&mut self, snapshot: Snapshot) -> Result<(), String> {
        let release = snapshot.release.validate_release()?;
        if self.releases.len() >= MAX_RELEASES || !self.releases.insert(release) {
            return Err("too many or duplicate snapshot releases".into());
        }
        if snapshot.libraries.len() != glibc::LIBRARIES.len()
            || glibc::LIBRARIES
                .iter()
                .any(|lib| !snapshot.libraries.contains_key(*lib))
        {
            return Err("matrix requires complete snapshots of the eight tracked libraries".into());
        }
        for (library, symbols) in snapshot.libraries {
            for symbol in symbols {
                if symbol.name.is_empty()
                    || symbol.name.len() > 1024
                    || !symbol.name.bytes().all(|b| b.is_ascii_graphic())
                    || symbol.version > release
                {
                    return Err("invalid matrix symbol name or future ABI version".into());
                }
                let key = (library.clone(), symbol.name, symbol.version);
                if !self.rows.contains_key(&key) {
                    self.name_bytes += key.0.len() + key.1.len();
                    if self.rows.len() >= MAX_ROWS || self.name_bytes > MAX_NAME_BYTES {
                        return Err("matrix exceeds row/name limits".into());
                    }
                }
                if self
                    .rows
                    .entry(key)
                    .or_default()
                    .insert(release, symbol.kind)
                    .is_some()
                {
                    return Err("duplicate library/symbol/version in a snapshot".into());
                }
            }
        }
        if self.rows.len() * self.releases.len() > MAX_CELLS {
            return Err("matrix exceeds two million cells".into());
        }
        Ok(())
    }

    /// Exact versioned export membership; not default binding or whole-link resolution.
    pub fn expect(
        &self,
        release: Version,
        library: &str,
        name: &str,
        version: Version,
    ) -> Expectation {
        if !self.releases.contains(&release) || !glibc::LIBRARIES.contains(&library) {
            return Expectation::Unknown;
        }
        self.rows
            .get(&(library.into(), name.into(), version))
            .and_then(|row| row.get(&release))
            .copied()
            .map_or(Expectation::Reject, Expectation::Accept)
    }

    /// Stable TSV with one row per library, symbol and version: the first
    /// snapshot that has it, then one column per release (`F`, `D:size`,
    /// `T:size` or `absent`). With `symbol`, only rows for that name.
    pub fn to_tsv(&self, symbol: Option<&str>) -> String {
        let mut text = String::from("target\tlibrary\tsymbol\tversion\tfirst_observed_snapshot");
        for release in &self.releases {
            write!(text, "\t{release}").unwrap();
        }
        text.push('\n');
        for ((library, name, version), row) in &self.rows {
            if symbol.is_some_and(|s| s != name) {
                continue;
            }
            write!(
                text,
                "{}\t{library}\t{name}\tGLIBC_{version}\t{}",
                glibc::TARGET,
                row.keys().next().unwrap()
            )
            .unwrap();
            for release in &self.releases {
                write!(text, "\t{}", cell(row.get(release).copied())).unwrap();
            }
            text.push('\n');
        }
        text
    }

    /// Stable TSV of the rows that are added, removed or changed between the
    /// `from` and `to` releases. With `symbol`, only rows for that name.
    ///
    /// # Errors
    ///
    /// Fails if either release is not in the matrix.
    pub fn diff_tsv(
        &self,
        from: Version,
        to: Version,
        symbol: Option<&str>,
    ) -> Result<String, String> {
        if !self.releases.contains(&from) || !self.releases.contains(&to) {
            return Err("diff requires two known snapshots; missing history is unknown".into());
        }
        let mut text =
            String::from("target\tfrom\tto\tchange\tlibrary\tsymbol\tversion\tbefore\tafter\n");
        for ((library, name, version), row) in &self.rows {
            if symbol.is_some_and(|s| s != name) {
                continue;
            }
            let old = row.get(&from).copied();
            let new = row.get(&to).copied();
            if old == new {
                continue;
            }
            let change = match (old, new) {
                (None, Some(_)) => "added",
                (Some(_), None) => "removed",
                _ => "changed",
            };
            writeln!(
                text,
                "{}\t{from}\t{to}\t{change}\t{library}\t{name}\tGLIBC_{version}\t{}\t{}",
                glibc::TARGET,
                cell(old),
                cell(new)
            )
            .unwrap();
        }
        Ok(text)
    }

    /// [`Matrix::diff_tsv`] between each pair of consecutive releases, under one
    /// header.
    pub fn changes_tsv(&self) -> String {
        let releases: Vec<_> = self.releases.iter().copied().collect();
        let mut text =
            String::from("target\tfrom\tto\tchange\tlibrary\tsymbol\tversion\tbefore\tafter\n");
        for pair in releases.windows(2) {
            let diff = self.diff_tsv(pair[0], pair[1], None).unwrap();
            text.push_str(diff.split_once('\n').unwrap().1);
        }
        text
    }
}