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('=') || e.key.contains('\0') {
241                Err(ValidateBuildRequestError::InvalidEnvVar(i))?
242            }
243            if e.value.contains(&0) {
244                Err(ValidateBuildRequestError::InvalidEnvVar(i))?
245            }
246        }
247        if !is_sorted(value.environment_vars.iter().map(|e| e.key.as_bytes())) {
248            Err(ValidateBuildRequestError::EnvVarNotSorted)?;
249        }
250
251        // validate build constraints
252        let constraints = value
253            .constraints
254            .map_or(Ok(HashSet::new()), |constraints| {
255                constraints
256                    .try_into()
257                    .map_err(ValidateBuildRequestError::InvalidBuildConstraints)
258            })?;
259
260        // validate additional_files
261        for (i, additional_file) in value.additional_files.iter().enumerate() {
262            if !is_clean_relative_path(&additional_file.path) {
263                Err(ValidateBuildRequestError::InvalidAdditionalFilePath(i))?
264            }
265        }
266        if !is_sorted(value.additional_files.iter().map(|e| e.path.as_bytes())) {
267            Err(ValidateBuildRequestError::AdditionalFilesNotSorted)?;
268        }
269
270        Ok(Self {
271            inputs,
272            command_args: value.command_args,
273            working_dir: PathBuf::from(value.working_dir),
274            scratch_paths: value.scratch_paths.iter().map(PathBuf::from).collect(),
275            inputs_dir: PathBuf::from(value.inputs_dir),
276            outputs: value.outputs.iter().map(PathBuf::from).collect(),
277            environment_vars: value.environment_vars.into_iter().map(Into::into).collect(),
278            constraints,
279            additional_files: value.additional_files.into_iter().map(Into::into).collect(),
280            refscan_needles: value.refscan_needles,
281        })
282    }
283}
284
285impl From<BuildResult> for BuildResponse {
286    fn from(value: BuildResult) -> Self {
287        Self {
288            outputs: value
289                .outputs
290                .into_iter()
291                .map(|output| build_response::Output {
292                    output: Some(snix_castore::proto::Entry::from_name_and_node(
293                        "".into(),
294                        output.node,
295                    )),
296                    needles: output.output_needles.into_iter().collect(),
297                })
298                .collect(),
299        }
300    }
301}
302
303impl TryFrom<BuildResponse> for BuildResult {
304    type Error = ValidateBuildResultError;
305
306    fn try_from(value: BuildResponse) -> Result<Self, Self::Error> {
307        Ok(Self {
308            outputs: value
309                .outputs
310                .into_iter()
311                .enumerate()
312                .map(|(i, output)| {
313                    let node = output
314                        .output
315                        .ok_or(ValidateBuildResultError::MissingOutputEntry(i))?
316                        .try_into_anonymous_node()
317                        .map_err(|_| ValidateBuildResultError::InvalidOutputEntry(i))?;
318
319                    Ok::<_, ValidateBuildResultError>(crate::buildservice::BuildOutput {
320                        node,
321                        output_needles: BTreeSet::from_iter(output.needles),
322                    })
323                })
324                .try_collect()?,
325        })
326    }
327}
328
329/// Errors that occur during the validation of
330/// [build_request::BuildConstraints] messages.
331#[derive(Debug, thiserror::Error)]
332pub enum ValidateBuildConstraintsError {
333    #[error("invalid system")]
334    InvalidSystem,
335
336    #[error("invalid available_ro_paths at position {0}")]
337    InvalidAvailableRoPaths(usize),
338
339    #[error("available_ro_paths not sorted")]
340    AvailableRoPathsNotSorted,
341}
342
343impl From<build_request::EnvVar> for crate::buildservice::EnvVar {
344    fn from(value: build_request::EnvVar) -> Self {
345        Self {
346            key: value.key,
347            value: value.value,
348        }
349    }
350}
351
352impl From<crate::buildservice::EnvVar> for build_request::EnvVar {
353    fn from(value: crate::buildservice::EnvVar) -> Self {
354        Self {
355            key: value.key,
356            value: value.value,
357        }
358    }
359}
360
361impl From<build_request::AdditionalFile> for crate::buildservice::AdditionalFile {
362    fn from(value: build_request::AdditionalFile) -> Self {
363        Self {
364            path: PathBuf::from(value.path),
365            contents: value.contents,
366        }
367    }
368}
369
370impl From<crate::buildservice::AdditionalFile> for build_request::AdditionalFile {
371    fn from(value: crate::buildservice::AdditionalFile) -> Self {
372        Self {
373            path: value
374                .path
375                .to_str()
376                .expect("Snix bug: expected a valid path")
377                .to_string(),
378            contents: value.contents,
379        }
380    }
381}
382
383impl TryFrom<build_request::BuildConstraints> for HashSet<crate::buildservice::BuildConstraints> {
384    type Error = ValidateBuildConstraintsError;
385    fn try_from(value: build_request::BuildConstraints) -> Result<Self, Self::Error> {
386        use crate::buildservice::BuildConstraints;
387
388        // validate system
389        if value.system.is_empty() {
390            Err(ValidateBuildConstraintsError::InvalidSystem)?;
391        }
392
393        let mut build_constraints = HashSet::from([
394            BuildConstraints::System(value.system),
395            BuildConstraints::MinMemory(value.min_memory),
396        ]);
397
398        // validate available_ro_paths
399        for (i, p) in value.available_ro_paths.iter().enumerate() {
400            if !is_clean_absolute_path(p) {
401                Err(ValidateBuildConstraintsError::InvalidAvailableRoPaths(i))?
402            } else {
403                build_constraints.insert(BuildConstraints::AvailableReadOnlyPath(PathBuf::from(p)));
404            }
405        }
406        if !is_sorted(value.available_ro_paths.iter().map(|e| e.as_bytes())) {
407            Err(ValidateBuildConstraintsError::AvailableRoPathsNotSorted)?;
408        }
409
410        if value.network_access {
411            build_constraints.insert(BuildConstraints::NetworkAccess);
412        }
413        if value.provide_bin_sh {
414            build_constraints.insert(BuildConstraints::ProvideBinSh);
415        }
416
417        Ok(build_constraints)
418    }
419}
420
421#[cfg(test)]
422// TODO: add testcases for constraints special cases. The default cases in the protos
423// should result in the constraints not being added. For example min_memory 0 can be omitted.
424// Also interesting testcases are "merging semantics". MimMemory(1) and MinMemory(100) will
425// result in mim_memory 100, multiple AvailableReadOnlyPaths need to be merged. Contradicting
426// system constraints need to fail somewhere (maybe an assertion, as only buggy code can construct it)
427mod tests {
428    use super::{is_clean_path, is_clean_relative_path};
429    use rstest::rstest;
430
431    #[rstest]
432    #[case::fail_trailing_slash("foo/bar/", false)]
433    #[case::fail_dotdot("foo/../bar", false)]
434    #[case::fail_singledot("foo/./bar", false)]
435    #[case::fail_unnecessary_slashes("foo//bar", false)]
436    #[case::fail_absolute_unnecessary_slashes("//foo/bar", false)]
437    #[case::ok_empty("", true)]
438    #[case::ok_relative("foo/bar", true)]
439    #[case::ok_absolute("/", true)]
440    #[case::ok_absolute2("/foo/bar", true)]
441    fn test_is_clean_path(#[case] s: &str, #[case] expected: bool) {
442        assert_eq!(is_clean_path(s), expected);
443    }
444
445    #[rstest]
446    #[case::fail_absolute("/", false)]
447    #[case::ok_relative("foo/bar", true)]
448    fn test_is_clean_relative_path(#[case] s: &str, #[case] expected: bool) {
449        assert_eq!(is_clean_relative_path(s), expected);
450    }
451
452    // TODO: add tests for BuildRequest validation itself
453}