Skip to main content

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