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;
12use crate::proto::build_update::build_failure;
13
14tonic::include_proto!("snix.build.v1");
15
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/// Checks a path to be without any '..' components, and clean (no superfluous
65/// slashes).
66fn is_clean_path<P: AsRef<Path>>(p: P) -> bool {
67    let p = p.as_ref();
68
69    // Look at all components, bail in case of ".", ".." and empty normal
70    // segments (superfluous slashes)
71    // We still need to assemble a cleaned PathBuf, and compare the OsString
72    // later, as .components() already does do some normalization before
73    // yielding.
74    let mut cleaned_p = PathBuf::new();
75    for component in p.components() {
76        match component {
77            std::path::Component::Prefix(_) => {}
78            std::path::Component::RootDir => {}
79            std::path::Component::CurDir => return false,
80            std::path::Component::ParentDir => return false,
81            std::path::Component::Normal(a) => {
82                if a.is_empty() {
83                    return false;
84                }
85            }
86        }
87        cleaned_p.push(component);
88    }
89
90    // if cleaned_p looks like p, we're good.
91    if cleaned_p.as_os_str() != p.as_os_str() {
92        return false;
93    }
94
95    true
96}
97
98fn is_clean_relative_path<P: AsRef<Path>>(p: P) -> bool {
99    if p.as_ref().is_absolute() {
100        return false;
101    }
102
103    is_clean_path(p)
104}
105
106fn is_clean_absolute_path<P: AsRef<Path>>(p: P) -> bool {
107    if !p.as_ref().is_absolute() {
108        return false;
109    }
110
111    is_clean_path(p)
112}
113
114/// Checks if a given list is sorted.
115fn is_sorted<I>(data: I) -> bool
116where
117    I: Iterator,
118    I::Item: Ord + Clone,
119{
120    data.tuple_windows().all(|(a, b)| a <= b)
121}
122
123impl From<crate::buildservice::BuildRequest> for BuildRequest {
124    fn from(value: crate::buildservice::BuildRequest) -> Self {
125        let constraints = if value.constraints.is_empty() {
126            None
127        } else {
128            let mut constraints = build_request::BuildConstraints::default();
129            for constraint in value.constraints {
130                use crate::buildservice::BuildConstraints;
131                match constraint {
132                    BuildConstraints::System(system) => constraints.system = system,
133                    BuildConstraints::MinMemory(min_memory) => constraints.min_memory = min_memory,
134                    BuildConstraints::AvailableReadOnlyPath(path) => {
135                        constraints.available_ro_paths.push(path)
136                    }
137                    BuildConstraints::ProvideBinSh => constraints.provide_bin_sh = true,
138                    BuildConstraints::NetworkAccess => constraints.network_access = true,
139                }
140            }
141            Some(constraints)
142        };
143        Self {
144            inputs: value
145                .inputs
146                .into_iter()
147                .map(|(name, node)| {
148                    snix_castore::proto::Entry::from_name_and_node(name.into(), node)
149                })
150                .collect(),
151            command_args: value.command_args,
152            working_dir: value.working_dir,
153            scratch_paths: value.scratch_paths,
154            inputs_dir: value.inputs_dir,
155            outputs: value.outputs,
156            environment_vars: value.environment_vars.into_iter().map(Into::into).collect(),
157            constraints,
158            additional_files: value.additional_files.into_iter().map(Into::into).collect(),
159            refscan_needles: value.refscan_needles,
160        }
161    }
162}
163
164impl TryFrom<BuildRequest> for crate::buildservice::BuildRequest {
165    type Error = ValidateBuildRequestError;
166    fn try_from(value: BuildRequest) -> Result<Self, Self::Error> {
167        // validate input names. Make sure they're sorted
168
169        let mut last_name: bytes::Bytes = "".into();
170        let mut inputs: BTreeMap<PathComponent, Node> = BTreeMap::new();
171        for (i, node) in value.inputs.iter().enumerate() {
172            let (name, node) = node
173                .clone()
174                .try_into_name_and_node()
175                .map_err(|e| ValidateBuildRequestError::InvalidInputNode(i, e))?;
176
177            if name.as_ref() <= last_name.as_ref() {
178                return Err(ValidateBuildRequestError::InputNodesNotSorted);
179            } else {
180                inputs.insert(name.clone(), node);
181                last_name = name.into();
182            }
183        }
184
185        // validate working_dir
186        if !is_clean_relative_path(&value.working_dir) {
187            Err(ValidateBuildRequestError::InvalidWorkingDir)?;
188        }
189
190        // validate scratch paths
191        for (i, p) in value.scratch_paths.iter().enumerate() {
192            if !is_clean_relative_path(p) {
193                Err(ValidateBuildRequestError::InvalidScratchPath(i))?
194            }
195        }
196        if !is_sorted(value.scratch_paths.iter().map(|e| e.as_bytes())) {
197            Err(ValidateBuildRequestError::ScratchPathsNotSorted)?;
198        }
199
200        // validate inputs_dir
201        if !is_clean_relative_path(&value.inputs_dir) {
202            Err(ValidateBuildRequestError::InvalidInputsDir)?;
203        }
204
205        // validate outputs
206        for (i, p) in value.outputs.iter().enumerate() {
207            if !is_clean_relative_path(p) {
208                Err(ValidateBuildRequestError::InvalidOutputPath(i))?
209            }
210        }
211        if !is_sorted(value.outputs.iter().map(|e| e.as_bytes())) {
212            Err(ValidateBuildRequestError::OutputsNotSorted)?;
213        }
214
215        // validate environment_vars.
216        for (i, e) in value.environment_vars.iter().enumerate() {
217            if e.key.is_empty() || e.key.contains('=') || e.key.contains('\0') {
218                Err(ValidateBuildRequestError::InvalidEnvVar(i))?
219            }
220            if e.value.contains(&0) {
221                Err(ValidateBuildRequestError::InvalidEnvVar(i))?
222            }
223        }
224        if !is_sorted(value.environment_vars.iter().map(|e| e.key.as_bytes())) {
225            Err(ValidateBuildRequestError::EnvVarNotSorted)?;
226        }
227
228        // validate build constraints
229        let constraints = value
230            .constraints
231            .map_or(Ok(HashSet::new()), |constraints| {
232                constraints
233                    .try_into()
234                    .map_err(ValidateBuildRequestError::InvalidBuildConstraints)
235            })?;
236
237        // validate additional_files
238        for (i, additional_file) in value.additional_files.iter().enumerate() {
239            if !is_clean_relative_path(&additional_file.path) {
240                Err(ValidateBuildRequestError::InvalidAdditionalFilePath(i))?
241            }
242        }
243        if !is_sorted(value.additional_files.iter().map(|e| e.path.as_bytes())) {
244            Err(ValidateBuildRequestError::AdditionalFilesNotSorted)?;
245        }
246
247        Ok(Self {
248            inputs,
249            command_args: value.command_args,
250            working_dir: value.working_dir,
251            scratch_paths: value.scratch_paths,
252            inputs_dir: value.inputs_dir,
253            outputs: value.outputs,
254            environment_vars: value.environment_vars.into_iter().map(Into::into).collect(),
255            constraints,
256            additional_files: value.additional_files.into_iter().map(Into::into).collect(),
257            refscan_needles: value.refscan_needles,
258        })
259    }
260}
261
262/// Errors that occur during the validation of
263/// [build_request::BuildConstraints] messages.
264#[derive(Debug, thiserror::Error)]
265pub enum ValidateBuildConstraintsError {
266    #[error("invalid system")]
267    InvalidSystem,
268
269    #[error("invalid available_ro_paths at position {0}")]
270    InvalidAvailableRoPaths(usize),
271
272    #[error("available_ro_paths not sorted")]
273    AvailableRoPathsNotSorted,
274}
275
276impl From<build_request::EnvVar> for crate::buildservice::EnvVar {
277    fn from(value: build_request::EnvVar) -> Self {
278        Self {
279            key: value.key,
280            value: value.value,
281        }
282    }
283}
284
285impl From<crate::buildservice::EnvVar> for build_request::EnvVar {
286    fn from(value: crate::buildservice::EnvVar) -> Self {
287        Self {
288            key: value.key,
289            value: value.value,
290        }
291    }
292}
293
294impl From<build_request::AdditionalFile> for crate::buildservice::AdditionalFile {
295    fn from(value: build_request::AdditionalFile) -> Self {
296        Self {
297            path: PathBuf::from(value.path),
298            contents: value.contents,
299        }
300    }
301}
302
303impl From<crate::buildservice::AdditionalFile> for build_request::AdditionalFile {
304    fn from(value: crate::buildservice::AdditionalFile) -> Self {
305        Self {
306            path: value
307                .path
308                .to_str()
309                .expect("Snix bug: expected a valid path")
310                .to_string(),
311            contents: value.contents,
312        }
313    }
314}
315
316impl TryFrom<build_request::BuildConstraints> for HashSet<crate::buildservice::BuildConstraints> {
317    type Error = ValidateBuildConstraintsError;
318    fn try_from(value: build_request::BuildConstraints) -> Result<Self, Self::Error> {
319        use crate::buildservice::BuildConstraints;
320
321        // validate system
322        if value.system.is_empty() {
323            Err(ValidateBuildConstraintsError::InvalidSystem)?;
324        }
325
326        let mut build_constraints = HashSet::from([
327            BuildConstraints::System(value.system),
328            BuildConstraints::MinMemory(value.min_memory),
329        ]);
330
331        // validate available_ro_paths
332        if !is_sorted(value.available_ro_paths.iter()) {
333            Err(ValidateBuildConstraintsError::AvailableRoPathsNotSorted)?;
334        }
335        for (i, p) in value.available_ro_paths.into_iter().enumerate() {
336            if !is_clean_absolute_path(&p) {
337                Err(ValidateBuildConstraintsError::InvalidAvailableRoPaths(i))?
338            } else {
339                build_constraints.insert(BuildConstraints::AvailableReadOnlyPath(p));
340            }
341        }
342
343        if value.network_access {
344            build_constraints.insert(BuildConstraints::NetworkAccess);
345        }
346        if value.provide_bin_sh {
347            build_constraints.insert(BuildConstraints::ProvideBinSh);
348        }
349
350        Ok(build_constraints)
351    }
352}
353
354impl From<buildservice::BuildUpdate> for BuildUpdate {
355    fn from(value: buildservice::BuildUpdate) -> Self {
356        Self {
357            kind: Some(match value {
358                buildservice::BuildUpdate::ProducedOutput {
359                    node,
360                    idx,
361                    refscan_needles,
362                } => build_update::Kind::ProducedOutput(build_update::ProducedOutput {
363                    entry: Some(snix_castore::proto::Entry::from_name_and_node(
364                        "".into(),
365                        node,
366                    )),
367                    idx,
368                    needles: refscan_needles.into_iter().collect(),
369                }),
370                buildservice::BuildUpdate::ProducedStdout(items) => {
371                    build_update::Kind::ProducedStdout(build_update::StdioChunk {
372                        chunk: items.into(),
373                    })
374                }
375                buildservice::BuildUpdate::ProducedStderr(items) => {
376                    build_update::Kind::ProducedStderr(build_update::StdioChunk {
377                        chunk: items.into(),
378                    })
379                }
380                buildservice::BuildUpdate::BuildFailure(build_failure) => {
381                    build_update::Kind::BuildFailure(build_update::BuildFailure {
382                        kind: Some(match build_failure {
383                            buildservice::BuildFailure::NonzeroExitCode => {
384                                build_failure::Kind::NonzeroExitCode(
385                                    build_failure::NonzeroExitCode {},
386                                )
387                            }
388                            buildservice::BuildFailure::MissingOutput { output } => {
389                                build_failure::Kind::MissingOutput(build_failure::MissingOutput {
390                                    output,
391                                })
392                            }
393                            buildservice::BuildFailure::Other { message } => {
394                                build_failure::Kind::Other(build_failure::Other { message })
395                            }
396                        }),
397                    })
398                }
399            }),
400        }
401    }
402}
403
404impl TryFrom<BuildUpdate> for buildservice::BuildUpdate {
405    type Error = ValidateBuildUpdateError;
406
407    fn try_from(value: BuildUpdate) -> Result<Self, Self::Error> {
408        let kind = value
409            .kind
410            .ok_or(ValidateBuildUpdateError::UnexpectedBuildUpdateKind)?;
411
412        Ok(match kind {
413            build_update::Kind::ProducedOutput(build_update::ProducedOutput {
414                entry: None,
415                ..
416            }) => {
417                return Err(ValidateBuildUpdateError::InvalidProducedOutputEntry);
418            }
419            build_update::Kind::ProducedOutput(build_update::ProducedOutput {
420                entry: Some(entry),
421                idx,
422                needles,
423            }) => {
424                let node = entry
425                    .try_into_anonymous_node()
426                    .or(Err(ValidateBuildUpdateError::InvalidProducedOutputEntry))?;
427                Self::ProducedOutput {
428                    node,
429                    idx,
430                    refscan_needles: BTreeSet::from_iter(needles),
431                }
432            }
433            build_update::Kind::ProducedStdout(produced_stdout) => {
434                Self::ProducedStdout(produced_stdout.chunk.into())
435            }
436            build_update::Kind::ProducedStderr(produced_stderr) => {
437                Self::ProducedStderr(produced_stderr.chunk.into())
438            }
439            build_update::Kind::BuildFailure(build_failure) => {
440                let kind = build_failure
441                    .kind
442                    .ok_or(ValidateBuildUpdateError::UnexpectedBuildFailureKind)?;
443                Self::BuildFailure(match kind {
444                    build_failure::Kind::NonzeroExitCode(_) => {
445                        buildservice::BuildFailure::NonzeroExitCode
446                    }
447                    build_failure::Kind::MissingOutput(build_failure::MissingOutput { output }) => {
448                        buildservice::BuildFailure::MissingOutput { output }
449                    }
450                    build_failure::Kind::Other(other) => buildservice::BuildFailure::Other {
451                        message: other.message,
452                    },
453                })
454            }
455        })
456    }
457}
458
459#[derive(thiserror::Error, Debug)]
460pub enum ValidateBuildUpdateError {
461    #[error("unexpected BuildUpdate kind")]
462    UnexpectedBuildUpdateKind,
463    #[error("unexpected BuildFailure kind")]
464    UnexpectedBuildFailureKind,
465    #[error("invalid entry in ProducedOutput")]
466    InvalidProducedOutputEntry,
467}
468
469impl From<ValidateBuildUpdateError> for crate::buildservice::BuildFailure {
470    fn from(value: ValidateBuildUpdateError) -> Self {
471        Self::Other {
472            message: format!("failed to validate BuildUpdate: {value}"),
473        }
474    }
475}
476
477#[cfg(test)]
478// TODO: add testcases for constraints special cases. The default cases in the protos
479// should result in the constraints not being added. For example min_memory 0 can be omitted.
480// Also interesting testcases are "merging semantics". MimMemory(1) and MinMemory(100) will
481// result in mim_memory 100, multiple AvailableReadOnlyPaths need to be merged. Contradicting
482// system constraints need to fail somewhere (maybe an assertion, as only buggy code can construct it)
483mod tests {
484    use super::{is_clean_path, is_clean_relative_path};
485    use rstest::rstest;
486
487    #[rstest]
488    #[case::fail_trailing_slash("foo/bar/", false)]
489    #[case::fail_dotdot("foo/../bar", false)]
490    #[case::fail_singledot("foo/./bar", false)]
491    #[case::fail_unnecessary_slashes("foo//bar", false)]
492    #[case::fail_absolute_unnecessary_slashes("//foo/bar", false)]
493    #[case::ok_empty("", true)]
494    #[case::ok_relative("foo/bar", true)]
495    #[case::ok_absolute("/", true)]
496    #[case::ok_absolute2("/foo/bar", true)]
497    fn test_is_clean_path(#[case] s: &str, #[case] expected: bool) {
498        assert_eq!(is_clean_path(s), expected);
499    }
500
501    #[rstest]
502    #[case::fail_absolute("/", false)]
503    #[case::ok_relative("foo/bar", true)]
504    fn test_is_clean_relative_path(#[case] s: &str, #[case] expected: bool) {
505        assert_eq!(is_clean_relative_path(s), expected);
506    }
507
508    // TODO: add tests for BuildRequest validation itself
509}