snix_build/proto/
mod.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet};
2use std::path::{Path, PathBuf};
3
4use itertools::Itertools;
5use snix_castore::{DirectoryError, Node, PathComponent};
6
7mod grpc_buildservice_wrapper;
8
9pub use grpc_buildservice_wrapper::GRPCBuildServiceWrapper;
10
11use crate::buildservice::BuildResult;
12
13tonic::include_proto!("snix.build.v1");
14
15#[cfg(feature = "tonic-reflection")]
16/// Compiled file descriptors for implementing [gRPC
17/// reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) with e.g.
18/// [`tonic_reflection`](https://docs.rs/tonic-reflection).
19pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("snix.build.v1");
20
21/// Errors that occur during the validation of [BuildRequest] messages.
22#[derive(Debug, thiserror::Error)]
23pub enum ValidateBuildRequestError {
24    #[error("invalid input node at position {0}: {1}")]
25    InvalidInputNode(usize, DirectoryError),
26
27    #[error("input nodes are not sorted by name")]
28    InputNodesNotSorted,
29
30    #[error("invalid working_dir")]
31    InvalidWorkingDir,
32
33    #[error("scratch_paths not sorted")]
34    ScratchPathsNotSorted,
35
36    #[error("invalid scratch path at position {0}")]
37    InvalidScratchPath(usize),
38
39    #[error("invalid inputs_dir")]
40    InvalidInputsDir,
41
42    #[error("invalid output path at position {0}")]
43    InvalidOutputPath(usize),
44
45    #[error("outputs not sorted")]
46    OutputsNotSorted,
47
48    #[error("invalid environment variable at position {0}")]
49    InvalidEnvVar(usize),
50
51    #[error("EnvVar not sorted by their keys")]
52    EnvVarNotSorted,
53
54    #[error("invalid build constraints: {0}")]
55    InvalidBuildConstraints(ValidateBuildConstraintsError),
56
57    #[error("invalid additional file path at position: {0}")]
58    InvalidAdditionalFilePath(usize),
59
60    #[error("additional_files not sorted")]
61    AdditionalFilesNotSorted,
62}
63
64/// Errors that occur during the validation of [BuildResult] messages.
65#[derive(Debug, thiserror::Error)]
66pub enum ValidateBuildResultError {
67    #[error("request field is unpopulated")]
68    MissingRequestField,
69    #[error("request is invalid")]
70    InvalidBuildRequest(ValidateBuildRequestError),
71    #[error("output entry {0} missing")]
72    MissingOutputEntry(usize),
73    #[error("output entry {0} invalid")]
74    InvalidOutputEntry(usize),
75}
76
77/// Checks a path to be without any '..' components, and clean (no superfluous
78/// slashes).
79fn is_clean_path<P: AsRef<Path>>(p: P) -> bool {
80    let p = p.as_ref();
81
82    // Look at all components, bail in case of ".", ".." and empty normal
83    // segments (superfluous slashes)
84    // We still need to assemble a cleaned PathBuf, and compare the OsString
85    // later, as .components() already does do some normalization before
86    // yielding.
87    let mut cleaned_p = PathBuf::new();
88    for component in p.components() {
89        match component {
90            std::path::Component::Prefix(_) => {}
91            std::path::Component::RootDir => {}
92            std::path::Component::CurDir => return false,
93            std::path::Component::ParentDir => return false,
94            std::path::Component::Normal(a) => {
95                if a.is_empty() {
96                    return false;
97                }
98            }
99        }
100        cleaned_p.push(component);
101    }
102
103    // if cleaned_p looks like p, we're good.
104    if cleaned_p.as_os_str() != p.as_os_str() {
105        return false;
106    }
107
108    true
109}
110
111fn is_clean_relative_path<P: AsRef<Path>>(p: P) -> bool {
112    if p.as_ref().is_absolute() {
113        return false;
114    }
115
116    is_clean_path(p)
117}
118
119fn is_clean_absolute_path<P: AsRef<Path>>(p: P) -> bool {
120    if !p.as_ref().is_absolute() {
121        return false;
122    }
123
124    is_clean_path(p)
125}
126
127/// Checks if a given list is sorted.
128fn is_sorted<I>(data: I) -> bool
129where
130    I: Iterator,
131    I::Item: Ord + Clone,
132{
133    data.tuple_windows().all(|(a, b)| a <= b)
134}
135
136fn path_to_string(path: &Path) -> String {
137    path.to_str()
138        .expect("Snix Bug: unable to convert Path to String")
139        .to_string()
140}
141
142impl From<crate::buildservice::BuildRequest> for BuildRequest {
143    fn from(value: crate::buildservice::BuildRequest) -> Self {
144        let constraints = if value.constraints.is_empty() {
145            None
146        } else {
147            let mut constraints = build_request::BuildConstraints::default();
148            for constraint in value.constraints {
149                use crate::buildservice::BuildConstraints;
150                match constraint {
151                    BuildConstraints::System(system) => constraints.system = system,
152                    BuildConstraints::MinMemory(min_memory) => constraints.min_memory = min_memory,
153                    BuildConstraints::AvailableReadOnlyPath(path) => {
154                        constraints.available_ro_paths.push(path_to_string(&path))
155                    }
156                    BuildConstraints::ProvideBinSh => constraints.provide_bin_sh = true,
157                    BuildConstraints::NetworkAccess => constraints.network_access = true,
158                }
159            }
160            Some(constraints)
161        };
162        Self {
163            inputs: value
164                .inputs
165                .into_iter()
166                .map(|(name, node)| {
167                    snix_castore::proto::Entry::from_name_and_node(name.into(), node)
168                })
169                .collect(),
170            command_args: value.command_args,
171            working_dir: path_to_string(&value.working_dir),
172            scratch_paths: value
173                .scratch_paths
174                .iter()
175                .map(|p| path_to_string(p))
176                .collect(),
177            inputs_dir: path_to_string(&value.inputs_dir),
178            outputs: value.outputs.iter().map(|p| path_to_string(p)).collect(),
179            environment_vars: value.environment_vars.into_iter().map(Into::into).collect(),
180            constraints,
181            additional_files: value.additional_files.into_iter().map(Into::into).collect(),
182            refscan_needles: value.refscan_needles,
183        }
184    }
185}
186
187impl TryFrom<BuildRequest> for crate::buildservice::BuildRequest {
188    type Error = ValidateBuildRequestError;
189    fn try_from(value: BuildRequest) -> Result<Self, Self::Error> {
190        // validate input names. Make sure they're sorted
191
192        let mut last_name: bytes::Bytes = "".into();
193        let mut inputs: BTreeMap<PathComponent, Node> = BTreeMap::new();
194        for (i, node) in value.inputs.iter().enumerate() {
195            let (name, node) = node
196                .clone()
197                .try_into_name_and_node()
198                .map_err(|e| ValidateBuildRequestError::InvalidInputNode(i, e))?;
199
200            if name.as_ref() <= last_name.as_ref() {
201                return Err(ValidateBuildRequestError::InputNodesNotSorted);
202            } else {
203                inputs.insert(name.clone(), node);
204                last_name = name.into();
205            }
206        }
207
208        // validate working_dir
209        if !is_clean_relative_path(&value.working_dir) {
210            Err(ValidateBuildRequestError::InvalidWorkingDir)?;
211        }
212
213        // validate scratch paths
214        for (i, p) in value.scratch_paths.iter().enumerate() {
215            if !is_clean_relative_path(p) {
216                Err(ValidateBuildRequestError::InvalidScratchPath(i))?
217            }
218        }
219        if !is_sorted(value.scratch_paths.iter().map(|e| e.as_bytes())) {
220            Err(ValidateBuildRequestError::ScratchPathsNotSorted)?;
221        }
222
223        // validate inputs_dir
224        if !is_clean_relative_path(&value.inputs_dir) {
225            Err(ValidateBuildRequestError::InvalidInputsDir)?;
226        }
227
228        // validate outputs
229        for (i, p) in value.outputs.iter().enumerate() {
230            if !is_clean_relative_path(p) {
231                Err(ValidateBuildRequestError::InvalidOutputPath(i))?
232            }
233        }
234        if !is_sorted(value.outputs.iter().map(|e| e.as_bytes())) {
235            Err(ValidateBuildRequestError::OutputsNotSorted)?;
236        }
237
238        // validate environment_vars.
239        for (i, e) in value.environment_vars.iter().enumerate() {
240            if e.key.is_empty() || e.key.contains('=') {
241                Err(ValidateBuildRequestError::InvalidEnvVar(i))?
242            }
243        }
244        if !is_sorted(value.environment_vars.iter().map(|e| e.key.as_bytes())) {
245            Err(ValidateBuildRequestError::EnvVarNotSorted)?;
246        }
247
248        // validate build constraints
249        let constraints = value
250            .constraints
251            .map_or(Ok(HashSet::new()), |constraints| {
252                constraints
253                    .try_into()
254                    .map_err(ValidateBuildRequestError::InvalidBuildConstraints)
255            })?;
256
257        // validate additional_files
258        for (i, additional_file) in value.additional_files.iter().enumerate() {
259            if !is_clean_relative_path(&additional_file.path) {
260                Err(ValidateBuildRequestError::InvalidAdditionalFilePath(i))?
261            }
262        }
263        if !is_sorted(value.additional_files.iter().map(|e| e.path.as_bytes())) {
264            Err(ValidateBuildRequestError::AdditionalFilesNotSorted)?;
265        }
266
267        Ok(Self {
268            inputs,
269            command_args: value.command_args,
270            working_dir: PathBuf::from(value.working_dir),
271            scratch_paths: value.scratch_paths.iter().map(PathBuf::from).collect(),
272            inputs_dir: PathBuf::from(value.inputs_dir),
273            outputs: value.outputs.iter().map(PathBuf::from).collect(),
274            environment_vars: value.environment_vars.into_iter().map(Into::into).collect(),
275            constraints,
276            additional_files: value.additional_files.into_iter().map(Into::into).collect(),
277            refscan_needles: value.refscan_needles,
278        })
279    }
280}
281
282impl From<BuildResult> for BuildResponse {
283    fn from(value: BuildResult) -> Self {
284        Self {
285            outputs: value
286                .outputs
287                .into_iter()
288                .map(|output| build_response::Output {
289                    output: Some(snix_castore::proto::Entry::from_name_and_node(
290                        "".into(),
291                        output.node,
292                    )),
293                    needles: output.output_needles.into_iter().collect(),
294                })
295                .collect(),
296        }
297    }
298}
299
300impl TryFrom<BuildResponse> for BuildResult {
301    type Error = ValidateBuildResultError;
302
303    fn try_from(value: BuildResponse) -> Result<Self, Self::Error> {
304        Ok(Self {
305            outputs: value
306                .outputs
307                .into_iter()
308                .enumerate()
309                .map(|(i, output)| {
310                    let node = output
311                        .output
312                        .ok_or(ValidateBuildResultError::MissingOutputEntry(i))?
313                        .try_into_anonymous_node()
314                        .map_err(|_| ValidateBuildResultError::InvalidOutputEntry(i))?;
315
316                    Ok::<_, ValidateBuildResultError>(crate::buildservice::BuildOutput {
317                        node,
318                        output_needles: BTreeSet::from_iter(output.needles),
319                    })
320                })
321                .try_collect()?,
322        })
323    }
324}
325
326/// Errors that occur during the validation of
327/// [build_request::BuildConstraints] messages.
328#[derive(Debug, thiserror::Error)]
329pub enum ValidateBuildConstraintsError {
330    #[error("invalid system")]
331    InvalidSystem,
332
333    #[error("invalid available_ro_paths at position {0}")]
334    InvalidAvailableRoPaths(usize),
335
336    #[error("available_ro_paths not sorted")]
337    AvailableRoPathsNotSorted,
338}
339
340impl From<build_request::EnvVar> for crate::buildservice::EnvVar {
341    fn from(value: build_request::EnvVar) -> Self {
342        Self {
343            key: value.key,
344            value: value.value,
345        }
346    }
347}
348
349impl From<crate::buildservice::EnvVar> for build_request::EnvVar {
350    fn from(value: crate::buildservice::EnvVar) -> Self {
351        Self {
352            key: value.key,
353            value: value.value,
354        }
355    }
356}
357
358impl From<build_request::AdditionalFile> for crate::buildservice::AdditionalFile {
359    fn from(value: build_request::AdditionalFile) -> Self {
360        Self {
361            path: PathBuf::from(value.path),
362            contents: value.contents,
363        }
364    }
365}
366
367impl From<crate::buildservice::AdditionalFile> for build_request::AdditionalFile {
368    fn from(value: crate::buildservice::AdditionalFile) -> Self {
369        Self {
370            path: value
371                .path
372                .to_str()
373                .expect("Snix bug: expected a valid path")
374                .to_string(),
375            contents: value.contents,
376        }
377    }
378}
379
380impl TryFrom<build_request::BuildConstraints> for HashSet<crate::buildservice::BuildConstraints> {
381    type Error = ValidateBuildConstraintsError;
382    fn try_from(value: build_request::BuildConstraints) -> Result<Self, Self::Error> {
383        use crate::buildservice::BuildConstraints;
384
385        // validate system
386        if value.system.is_empty() {
387            Err(ValidateBuildConstraintsError::InvalidSystem)?;
388        }
389
390        let mut build_constraints = HashSet::from([
391            BuildConstraints::System(value.system),
392            BuildConstraints::MinMemory(value.min_memory),
393        ]);
394
395        // validate available_ro_paths
396        for (i, p) in value.available_ro_paths.iter().enumerate() {
397            if !is_clean_absolute_path(p) {
398                Err(ValidateBuildConstraintsError::InvalidAvailableRoPaths(i))?
399            } else {
400                build_constraints.insert(BuildConstraints::AvailableReadOnlyPath(PathBuf::from(p)));
401            }
402        }
403        if !is_sorted(value.available_ro_paths.iter().map(|e| e.as_bytes())) {
404            Err(ValidateBuildConstraintsError::AvailableRoPathsNotSorted)?;
405        }
406
407        if value.network_access {
408            build_constraints.insert(BuildConstraints::NetworkAccess);
409        }
410        if value.provide_bin_sh {
411            build_constraints.insert(BuildConstraints::ProvideBinSh);
412        }
413
414        Ok(build_constraints)
415    }
416}
417
418#[cfg(test)]
419// TODO: add testcases for constraints special cases. The default cases in the protos
420// should result in the constraints not being added. For example min_memory 0 can be omitted.
421// Also interesting testcases are "merging semantics". MimMemory(1) and MinMemory(100) will
422// result in mim_memory 100, multiple AvailableReadOnlyPaths need to be merged. Contradicting
423// system constraints need to fail somewhere (maybe an assertion, as only buggy code can construct it)
424mod tests {
425    use super::{is_clean_path, is_clean_relative_path};
426    use rstest::rstest;
427
428    #[rstest]
429    #[case::fail_trailing_slash("foo/bar/", false)]
430    #[case::fail_dotdot("foo/../bar", false)]
431    #[case::fail_singledot("foo/./bar", false)]
432    #[case::fail_unnecessary_slashes("foo//bar", false)]
433    #[case::fail_absolute_unnecessary_slashes("//foo/bar", false)]
434    #[case::ok_empty("", true)]
435    #[case::ok_relative("foo/bar", true)]
436    #[case::ok_absolute("/", true)]
437    #[case::ok_absolute2("/foo/bar", true)]
438    fn test_is_clean_path(#[case] s: &str, #[case] expected: bool) {
439        assert_eq!(is_clean_path(s), expected);
440    }
441
442    #[rstest]
443    #[case::fail_absolute("/", false)]
444    #[case::ok_relative("foo/bar", true)]
445    fn test_is_clean_relative_path(#[case] s: &str, #[case] expected: bool) {
446        assert_eq!(is_clean_relative_path(s), expected);
447    }
448
449    // TODO: add tests for BuildRequest validation itself
450}