Change-Id: Ifadd190e10ec22570ab3ccb4df54f64ae5ef0a44 Reviewed-on: https://cl.tvl.fyi/c/depot/+/12674 Tested-by: BuildkiteCI Reviewed-by: flokli <flokli@flokli.de>
391 lines
13 KiB
Rust
391 lines
13 KiB
Rust
use std::collections::{BTreeMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use itertools::Itertools;
|
|
use tvix_castore::{DirectoryError, Node, PathComponent};
|
|
|
|
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, DirectoryError),
|
|
|
|
#[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)
|
|
}
|
|
|
|
fn path_to_string(path: &Path) -> String {
|
|
path.to_str()
|
|
.expect("Tvix Bug: unable to convert Path to String")
|
|
.to_string()
|
|
}
|
|
|
|
impl From<crate::buildservice::BuildRequest> for BuildRequest {
|
|
fn from(value: crate::buildservice::BuildRequest) -> Self {
|
|
let constraints = if value.constraints.is_empty() {
|
|
None
|
|
} else {
|
|
let mut constraints = build_request::BuildConstraints::default();
|
|
for constraint in value.constraints {
|
|
use crate::buildservice::BuildConstraints;
|
|
match constraint {
|
|
BuildConstraints::System(system) => constraints.system = system,
|
|
BuildConstraints::MinMemory(min_memory) => constraints.min_memory = min_memory,
|
|
BuildConstraints::AvailableReadOnlyPath(path) => {
|
|
constraints.available_ro_paths.push(path_to_string(&path))
|
|
}
|
|
BuildConstraints::ProvideBinSh => constraints.provide_bin_sh = true,
|
|
BuildConstraints::NetworkAccess => constraints.network_access = true,
|
|
}
|
|
}
|
|
Some(constraints)
|
|
};
|
|
Self {
|
|
inputs: value
|
|
.inputs
|
|
.into_iter()
|
|
.map(|(name, node)| {
|
|
tvix_castore::proto::Node::from_name_and_node(name.into(), node)
|
|
})
|
|
.collect(),
|
|
command_args: value.command_args,
|
|
working_dir: path_to_string(&value.working_dir),
|
|
scratch_paths: value
|
|
.scratch_paths
|
|
.iter()
|
|
.map(|p| path_to_string(p))
|
|
.collect(),
|
|
inputs_dir: path_to_string(&value.inputs_dir),
|
|
outputs: value.outputs.iter().map(|p| path_to_string(p)).collect(),
|
|
environment_vars: value.environment_vars.into_iter().map(Into::into).collect(),
|
|
constraints,
|
|
additional_files: value.additional_files.into_iter().map(Into::into).collect(),
|
|
refscan_needles: value.refscan_needles,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<BuildRequest> for crate::buildservice::BuildRequest {
|
|
type Error = ValidateBuildRequestError;
|
|
fn try_from(value: BuildRequest) -> Result<Self, Self::Error> {
|
|
// validate input names. Make sure they're sorted
|
|
|
|
let mut last_name: bytes::Bytes = "".into();
|
|
let mut inputs: BTreeMap<PathComponent, Node> = BTreeMap::new();
|
|
for (i, node) in value.inputs.iter().enumerate() {
|
|
let (name, node) = node
|
|
.clone()
|
|
.try_into_name_and_node()
|
|
.map_err(|e| ValidateBuildRequestError::InvalidInputNode(i, e))?;
|
|
|
|
if name.as_ref() <= last_name.as_ref() {
|
|
return Err(ValidateBuildRequestError::InputNodesNotSorted);
|
|
} else {
|
|
inputs.insert(name.clone(), node);
|
|
last_name = name.into();
|
|
}
|
|
}
|
|
|
|
// validate working_dir
|
|
if !is_clean_relative_path(&value.working_dir) {
|
|
Err(ValidateBuildRequestError::InvalidWorkingDir)?;
|
|
}
|
|
|
|
// validate scratch paths
|
|
for (i, p) in value.scratch_paths.iter().enumerate() {
|
|
if !is_clean_relative_path(p) {
|
|
Err(ValidateBuildRequestError::InvalidScratchPath(i))?
|
|
}
|
|
}
|
|
if !is_sorted(value.scratch_paths.iter().map(|e| e.as_bytes())) {
|
|
Err(ValidateBuildRequestError::ScratchPathsNotSorted)?;
|
|
}
|
|
|
|
// validate inputs_dir
|
|
if !is_clean_relative_path(&value.inputs_dir) {
|
|
Err(ValidateBuildRequestError::InvalidInputsDir)?;
|
|
}
|
|
|
|
// validate outputs
|
|
for (i, p) in value.outputs.iter().enumerate() {
|
|
if !is_clean_relative_path(p) {
|
|
Err(ValidateBuildRequestError::InvalidOutputPath(i))?
|
|
}
|
|
}
|
|
if !is_sorted(value.outputs.iter().map(|e| e.as_bytes())) {
|
|
Err(ValidateBuildRequestError::OutputsNotSorted)?;
|
|
}
|
|
|
|
// validate environment_vars.
|
|
for (i, e) in value.environment_vars.iter().enumerate() {
|
|
if e.key.is_empty() || e.key.contains('=') {
|
|
Err(ValidateBuildRequestError::InvalidEnvVar(i))?
|
|
}
|
|
}
|
|
if !is_sorted(value.environment_vars.iter().map(|e| e.key.as_bytes())) {
|
|
Err(ValidateBuildRequestError::EnvVarNotSorted)?;
|
|
}
|
|
|
|
// validate build constraints
|
|
let constraints = value
|
|
.constraints
|
|
.map_or(Ok(HashSet::new()), |constraints| {
|
|
constraints
|
|
.try_into()
|
|
.map_err(ValidateBuildRequestError::InvalidBuildConstraints)
|
|
})?;
|
|
|
|
// validate additional_files
|
|
for (i, additional_file) in value.additional_files.iter().enumerate() {
|
|
if !is_clean_relative_path(&additional_file.path) {
|
|
Err(ValidateBuildRequestError::InvalidAdditionalFilePath(i))?
|
|
}
|
|
}
|
|
if !is_sorted(value.additional_files.iter().map(|e| e.path.as_bytes())) {
|
|
Err(ValidateBuildRequestError::AdditionalFilesNotSorted)?;
|
|
}
|
|
|
|
Ok(Self {
|
|
inputs,
|
|
command_args: value.command_args,
|
|
working_dir: PathBuf::from(value.working_dir),
|
|
scratch_paths: value.scratch_paths.iter().map(PathBuf::from).collect(),
|
|
inputs_dir: PathBuf::from(value.inputs_dir),
|
|
outputs: value.outputs.iter().map(PathBuf::from).collect(),
|
|
environment_vars: value.environment_vars.into_iter().map(Into::into).collect(),
|
|
constraints,
|
|
additional_files: value.additional_files.into_iter().map(Into::into).collect(),
|
|
refscan_needles: value.refscan_needles,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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 From<build_request::EnvVar> for crate::buildservice::EnvVar {
|
|
fn from(value: build_request::EnvVar) -> Self {
|
|
Self {
|
|
key: value.key,
|
|
value: value.value,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<crate::buildservice::EnvVar> for build_request::EnvVar {
|
|
fn from(value: crate::buildservice::EnvVar) -> Self {
|
|
Self {
|
|
key: value.key,
|
|
value: value.value,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<build_request::AdditionalFile> for crate::buildservice::AdditionalFile {
|
|
fn from(value: build_request::AdditionalFile) -> Self {
|
|
Self {
|
|
path: PathBuf::from(value.path),
|
|
contents: value.contents,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<crate::buildservice::AdditionalFile> for build_request::AdditionalFile {
|
|
fn from(value: crate::buildservice::AdditionalFile) -> Self {
|
|
Self {
|
|
path: value
|
|
.path
|
|
.to_str()
|
|
.expect("Tvix bug: expected a valid path")
|
|
.to_string(),
|
|
contents: value.contents,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<build_request::BuildConstraints> for HashSet<crate::buildservice::BuildConstraints> {
|
|
type Error = ValidateBuildConstraintsError;
|
|
fn try_from(value: build_request::BuildConstraints) -> Result<Self, Self::Error> {
|
|
use crate::buildservice::BuildConstraints;
|
|
|
|
// validate system
|
|
if value.system.is_empty() {
|
|
Err(ValidateBuildConstraintsError::InvalidSystem)?;
|
|
}
|
|
|
|
let mut build_constraints = HashSet::from([
|
|
BuildConstraints::System(value.system),
|
|
BuildConstraints::MinMemory(value.min_memory),
|
|
]);
|
|
|
|
// validate available_ro_paths
|
|
for (i, p) in value.available_ro_paths.iter().enumerate() {
|
|
if !is_clean_absolute_path(p) {
|
|
Err(ValidateBuildConstraintsError::InvalidAvailableRoPaths(i))?
|
|
} else {
|
|
build_constraints.insert(BuildConstraints::AvailableReadOnlyPath(PathBuf::from(p)));
|
|
}
|
|
}
|
|
if !is_sorted(value.available_ro_paths.iter().map(|e| e.as_bytes())) {
|
|
Err(ValidateBuildConstraintsError::AvailableRoPathsNotSorted)?;
|
|
}
|
|
|
|
if value.network_access {
|
|
build_constraints.insert(BuildConstraints::NetworkAccess);
|
|
}
|
|
if value.provide_bin_sh {
|
|
build_constraints.insert(BuildConstraints::ProvideBinSh);
|
|
}
|
|
|
|
Ok(build_constraints)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
// TODO: add testcases for constraints special cases. The default cases in the protos
|
|
// should result in the constraints not being added. For example min_memory 0 can be omitted.
|
|
// Also interesting testcases are "merging semantics". MimMemory(1) and MinMemory(100) will
|
|
// result in mim_memory 100, multiple AvailableReadOnlyPaths need to be merged. Contradicting
|
|
// system constraints need to fail somewhere (maybe an assertion, as only buggy code can construct it)
|
|
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
|
|
}
|