Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

crates/heimdall-scan/src/input.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The scan-input envelope: the one input file of a static-scanner job.
//! Heimdall is a defensive sandbox; the guest reads this from untrusted
//! hands, so the decoder is strict and never allocates by a declared length.
//!
//! ```text
//! magic    "heimdall-scan-input v1\n"           (23 bytes)
//! count    u32 big endian                        (1 ..= 16 sections)
//! section  name_len u8 | name | body_len u64 BE | body    (count times)
//! ```
//!
//! Section names are [`DataKind`] names plus `artifact`, each at most once;
//! `artifact` is required. Nothing may follow the last section.

use std::collections::BTreeMap;
use std::fmt;

/// The envelope's first bytes.
pub const MAGIC: &[u8] = b"heimdall-scan-input v1\n";
/// The section that holds the package artifact.
pub const ARTIFACT: &str = "artifact";
/// At most this many sections.
pub const MAX_SECTIONS: u32 = 16;

/// A data snapshot the caller hands a scanner. Each is optional on the wire;
/// a check whose data is missing answers `unknown` (`data-missing`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DataKind {
    /// Popular package names of the purl's ecosystem, one per line (UTF-8;
    /// blank lines and lines starting with `#` are skipped). Typosquat check.
    PopularNames,
    /// Internal-only name prefixes (`@acme/`, `acme-`), one per line, same
    /// syntax. Dependency-confusion check; an empty list means "none".
    InternalNamespaces,
    /// The registry's own document for this package, verbatim: the crates.io
    /// `/api/v1/crates/<name>` JSON, the npm packument, the PyPI
    /// `/pypi/<name>/json` JSON, or the module's `go.mod`. Registry check.
    Registry,
    /// OSV records of malicious packages (OpenSSF `MAL-…`), one record or a
    /// JSON array (an empty array is a real "none"). Malicious check.
    Malicious,
}

impl DataKind {
    pub const ALL: [DataKind; 4] = [
        DataKind::PopularNames,
        DataKind::InternalNamespaces,
        DataKind::Registry,
        DataKind::Malicious,
    ];

    /// The section name on the wire.
    pub fn name(self) -> &'static str {
        match self {
            DataKind::PopularNames => "popular-names",
            DataKind::InternalNamespaces => "internal-namespaces",
            DataKind::Registry => "registry",
            DataKind::Malicious => "malicious",
        }
    }

    pub fn from_name(s: &str) -> Option<DataKind> {
        DataKind::ALL.into_iter().find(|k| k.name() == s)
    }
}

impl fmt::Display for DataKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.name())
    }
}

/// An envelope to send (the caller side).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScanInput {
    pub artifact: Vec<u8>,
    pub data: BTreeMap<DataKind, Vec<u8>>,
}

/// A decoded envelope, borrowing the input (the guest side).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScanInputRef<'a> {
    pub artifact: &'a [u8],
    pub data: BTreeMap<DataKind, &'a [u8]>,
}

/// Why an envelope was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputError {
    BadMagic,
    Truncated(&'static str),
    SectionCount(u32),
    UnknownSection(String),
    DuplicateSection(String),
    NoArtifact,
    TrailingBytes(usize),
}

impl fmt::Display for InputError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            InputError::BadMagic => write!(f, "not a scan-input envelope (bad magic)"),
            InputError::Truncated(what) => write!(f, "envelope truncated in the {what}"),
            InputError::SectionCount(n) => {
                write!(f, "{n} sections; an envelope holds 1 to {MAX_SECTIONS}")
            }
            InputError::UnknownSection(s) => write!(f, "unknown section {s:?}"),
            InputError::DuplicateSection(s) => write!(f, "section {s:?} given twice"),
            InputError::NoArtifact => write!(f, "no `artifact` section"),
            InputError::TrailingBytes(n) => write!(f, "{n} bytes after the last section"),
        }
    }
}

impl std::error::Error for InputError {}

impl ScanInput {
    pub fn new(artifact: impl Into<Vec<u8>>) -> ScanInput {
        ScanInput {
            artifact: artifact.into(),
            data: BTreeMap::new(),
        }
    }

    /// Add (or replace) one data snapshot.
    pub fn with(mut self, kind: DataKind, bytes: impl Into<Vec<u8>>) -> ScanInput {
        self.data.insert(kind, bytes.into());
        self
    }

    /// The envelope bytes: `artifact` first, then the data in [`DataKind`]
    /// order, so equal inputs give equal bytes.
    pub fn encode(&self) -> Vec<u8> {
        let total: usize = self.artifact.len() + self.data.values().map(Vec::len).sum::<usize>();
        let mut out = Vec::with_capacity(MAGIC.len() + 4 + total + 32 * (1 + self.data.len()));
        out.extend_from_slice(MAGIC);
        out.extend_from_slice(&(1 + self.data.len() as u32).to_be_bytes());
        push_section(&mut out, ARTIFACT, &self.artifact);
        for (k, v) in &self.data {
            push_section(&mut out, k.name(), v);
        }
        out
    }
}

fn push_section(out: &mut Vec<u8>, name: &str, body: &[u8]) {
    out.push(name.len() as u8);
    out.extend_from_slice(name.as_bytes());
    out.extend_from_slice(&(body.len() as u64).to_be_bytes());
    out.extend_from_slice(body);
}

impl<'a> ScanInputRef<'a> {
    /// Decode an envelope. Strict: the magic, a section count in range, only
    /// known names, each once, `artifact` present, no trailing bytes.
    pub fn decode(b: &'a [u8]) -> Result<ScanInputRef<'a>, InputError> {
        let rest = b.strip_prefix(MAGIC).ok_or(InputError::BadMagic)?;
        let (count, mut rest) = take(rest, 4, "section count")?;
        let count = u32::from_be_bytes(count.try_into().unwrap());
        if count == 0 || count > MAX_SECTIONS {
            return Err(InputError::SectionCount(count));
        }
        let mut artifact = None;
        let mut data = BTreeMap::new();
        for _ in 0..count {
            let (len, r) = take(rest, 1, "section name length")?;
            let (name, r) = take(r, len[0] as usize, "section name")?;
            let (blen, r) = take(r, 8, "section length")?;
            let blen = u64::from_be_bytes(blen.try_into().unwrap());
            let blen = usize::try_from(blen).map_err(|_| InputError::Truncated("section body"))?;
            let (body, r) = take(r, blen, "section body")?;
            rest = r;
            let name = std::str::from_utf8(name)
                .map_err(|_| InputError::UnknownSection(String::from_utf8_lossy(name).into()))?;
            if name == ARTIFACT {
                if artifact.replace(body).is_some() {
                    return Err(InputError::DuplicateSection(name.into()));
                }
            } else {
                let kind = DataKind::from_name(name)
                    .ok_or_else(|| InputError::UnknownSection(name.into()))?;
                if data.insert(kind, body).is_some() {
                    return Err(InputError::DuplicateSection(name.into()));
                }
            }
        }
        if !rest.is_empty() {
            return Err(InputError::TrailingBytes(rest.len()));
        }
        Ok(ScanInputRef {
            artifact: artifact.ok_or(InputError::NoArtifact)?,
            data,
        })
    }
}

fn take<'a>(b: &'a [u8], n: usize, what: &'static str) -> Result<(&'a [u8], &'a [u8]), InputError> {
    if b.len() < n {
        Err(InputError::Truncated(what))
    } else {
        Ok(b.split_at(n))
    }
}