about summary refs log tree commit diff
path: root/tvix/build/src/proto/mod.rs
use std::path::{Path, PathBuf};

use itertools::Itertools;
use tvix_castore::proto::{NamedNode, ValidateNodeError};

mod grpc_buildservice_wrapper;

pub use grpc_buildservice_wrapper::GRPCBuildServiceWrapper;

tonic::include_proto!("tvix.build.v1");

#[cfg(feature = "tonic-reflection")]
/// Compiled file descriptors for implementing [gRPC
/// reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) with e.g.
/// [`tonic_reflection`](https://docs.rs/tonic-reflection).
pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("tvix.build.v1");

/// Errors that occur during the validation of [BuildRequest] messages.
#[derive(Debug, thiserror::Error)]
pub enum ValidateBuildRequestError {
    #[error("invalid input node at position {0}: {1}")]
    InvalidInputNode(usize, ValidateNodeError),

    #[error("input nodes are not sorted by name")]
    InputNodesNotSorted,

    #[error("invalid working_dir")]
    InvalidWorkingDir,

    #[error("scratch_paths not sorted")]
    ScratchPathsNotSorted,

    #[error("invalid scratch path at position {0}")]
    InvalidScratchPath(usize),

    #[error("invalid inputs_dir")]
    InvalidInputsDir,

    #[error("invalid output path at position {0}")]
    InvalidOutputPath(usize),

    #[error("outputs not sorted")]
    OutputsNotSorted,

    #[error("invalid environment variable at position {0}")]
    InvalidEnvVar(usize),

    #[error("EnvVar not sorted by their keys")]
    EnvVarNotSorted,

    #[error("invalid build constraints: {0}")]
    InvalidBuildConstraints(ValidateBuildConstraintsError),

    #[error("invalid additional file path at position: {0}")]
    InvalidAdditionalFilePath(usize),

    #[error("additional_files not sorted")]
    AdditionalFilesNotSorted,
}

/// Checks a path to be without any '..' components, and clean (no superfluous
/// slashes).
fn is_clean_path<P: AsRef<Path>>(p: P) -> bool {
    let p = p.as_ref();

    // Look at all components, bail in case of ".", ".." and empty normal
    // segments (superfluous slashes)
    // We still need to assemble a cleaned PathBuf, and compare the OsString
    // later, as .components() already does do some normalization before
    // yielding.
    let mut cleaned_p = PathBuf::new();
    for component in p.components() {
        match component {
            std::path::Component::Prefix(_) => {}
            std::path::Component::RootDir => {}
            std::path::Component::CurDir => return false,
            std::path::Component::ParentDir => return false,
            std::path::Component::Normal(a) => {
                if a.is_empty() {
                    return false;
                }
            }
        }
        cleaned_p.push(component);
    }

    // if cleaned_p looks like p, we're good.
    if cleaned_p.as_os_str() != p.as_os_str() {
        return false;
    }

    true
}

fn is_clean_relative_path<P: AsRef<Path>>(p: P) -> bool {
    if p.as_ref().is_absolute() {
        return false;
    }

    is_clean_path(p)
}

fn is_clean_absolute_path<P: AsRef<Path>>(p: P) -> bool {
    if !p.as_ref().is_absolute() {
        return false;
    }

    is_clean_path(p)
}

/// Checks if a given list is sorted.
fn is_sorted<I>(data: I) -> bool
where
    I: Iterator,
    I::Item: Ord + Clone,
{
    data.tuple_windows().all(|(a, b)| a <= b)
}

impl BuildRequest {
    /// Ensures the build request is valid.
    /// This means, all input nodes need to be valid, paths in lists need to be sorted,
    /// and all restrictions around paths themselves (relative, clean, …) need
    // to be fulfilled.
    pub fn validate(&self) -> Result<(), ValidateBuildRequestError> {
        // validate all input nodes
        for (i, n) in self.inputs.iter().enumerate() {
            // ensure the input node itself is valid
            n.validate()
                .map_err(|e| ValidateBuildRequestError::InvalidInputNode(i, e))?;
        }

        // now we can look at the names, and make sure they're sorted.
        if !is_sorted(
            self.inputs
                .iter()
                .map(|e| e.node.as_ref().unwrap().get_name()),
        ) {
            Err(ValidateBuildRequestError::InputNodesNotSorted)?
        }

        // validate working_dir
        if !is_clean_relative_path(&self.working_dir) {
            Err(ValidateBuildRequestError::InvalidWorkingDir)?;
        }

        // validate scratch paths
        for (i, p) in self.scratch_paths.iter().enumerate() {
            if !is_clean_relative_path(p) {
                Err(ValidateBuildRequestError::InvalidScratchPath(i))?
            }
        }
        if !is_sorted(self.scratch_paths.iter().map(|e| e.as_bytes())) {
            Err(ValidateBuildRequestError::ScratchPathsNotSorted)?;
        }

        // validate inputs_dir
        if !is_clean_relative_path(&self.inputs_dir) {
            Err(ValidateBuildRequestError::InvalidInputsDir)?;
        }

        // validate outputs
        for (i, p) in self.outputs.iter().enumerate() {
            if !is_clean_relative_path(p) {
                Err(ValidateBuildRequestError::InvalidOutputPath(i))?
            }
        }
        if !is_sorted(self.outputs.iter().map(|e| e.as_bytes())) {
            Err(ValidateBuildRequestError::OutputsNotSorted)?;
        }

        // validate environment_vars.
        for (i, e) in self.environment_vars.iter().enumerate() {
            if e.key.is_empty() || e.key.contains('=') {
                Err(ValidateBuildRequestError::InvalidEnvVar(i))?
            }
        }
        if !is_sorted(self.environment_vars.iter().map(|e| e.key.as_bytes())) {
            Err(ValidateBuildRequestError::EnvVarNotSorted)?;
        }

        // validate build constraints
        if let Some(constraints) = self.constraints.as_ref() {
            constraints
                .validate()
                .map_err(ValidateBuildRequestError::InvalidBuildConstraints)?;
        }

        // validate additional_files
        for (i, additional_file) in self.additional_files.iter().enumerate() {
            if !is_clean_relative_path(&additional_file.path) {
                Err(ValidateBuildRequestError::InvalidAdditionalFilePath(i))?
            }
        }
        if !is_sorted(self.additional_files.iter().map(|e| e.path.as_bytes())) {
            Err(ValidateBuildRequestError::AdditionalFilesNotSorted)?;
        }

        Ok(())
    }
}

/// Errors that occur during the validation of
/// [build_request::BuildConstraints] messages.
#[derive(Debug, thiserror::Error)]
pub enum ValidateBuildConstraintsError {
    #[error("invalid system")]
    InvalidSystem,

    #[error("invalid available_ro_paths at position {0}")]
    InvalidAvailableRoPaths(usize),

    #[error("available_ro_paths not sorted")]
    AvailableRoPathsNotSorted,
}

impl build_request::BuildConstraints {
    pub fn validate(&self) -> Result<(), ValidateBuildConstraintsError> {
        // validate system
        if self.system.is_empty() {
            Err(ValidateBuildConstraintsError::InvalidSystem)?;
        }
        // validate available_ro_paths
        for (i, p) in self.available_ro_paths.iter().enumerate() {
            if !is_clean_absolute_path(p) {
                Err(ValidateBuildConstraintsError::InvalidAvailableRoPaths(i))?
            }
        }
        if !is_sorted(self.available_ro_paths.iter().map(|e| e.as_bytes())) {
            Err(ValidateBuildConstraintsError::AvailableRoPathsNotSorted)?;
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::{is_clean_path, is_clean_relative_path};
    use rstest::rstest;

    #[rstest]
    #[case::fail_trailing_slash("foo/bar/", false)]
    #[case::fail_dotdot("foo/../bar", false)]
    #[case::fail_singledot("foo/./bar", false)]
    #[case::fail_unnecessary_slashes("foo//bar", false)]
    #[case::fail_absolute_unnecessary_slashes("//foo/bar", false)]
    #[case::ok_empty("", true)]
    #[case::ok_relative("foo/bar", true)]
    #[case::ok_absolute("/", true)]
    #[case::ok_absolute2("/foo/bar", true)]
    fn test_is_clean_path(#[case] s: &str, #[case] expected: bool) {
        assert_eq!(is_clean_path(s), expected);
    }

    #[rstest]
    #[case::fail_absolute("/", false)]
    #[case::ok_relative("foo/bar", true)]
    fn test_is_clean_relative_path(#[case] s: &str, #[case] expected: bool) {
        assert_eq!(is_clean_relative_path(s), expected);
    }

    // TODO: add tests for BuildRequest validation itself
}