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))
}
}