cyclonedx_bom/models/
code.rs

1/*
2 * This file is part of CycloneDX Rust Cargo.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 *
16 * SPDX-License-Identifier: Apache-2.0
17 */
18
19use crate::{
20    external_models::{
21        date_time::DateTime,
22        normalized_string::{validate_normalized_string, NormalizedString},
23        uri::{validate_uri, Uri},
24        validate_date_time,
25    },
26    validation::{Validate, ValidationContext, ValidationError, ValidationResult},
27};
28
29use super::{attached_text::AttachedText, bom::SpecVersion};
30
31#[derive(Clone, Debug, PartialEq, Eq, Hash)]
32pub struct Commit {
33    pub uid: Option<NormalizedString>,
34    pub url: Option<Uri>,
35    pub author: Option<IdentifiableAction>,
36    pub committer: Option<IdentifiableAction>,
37    pub message: Option<NormalizedString>,
38}
39
40impl Validate for Commit {
41    fn validate_version(&self, version: SpecVersion) -> ValidationResult {
42        ValidationContext::new()
43            .add_field_option("uid", self.uid.as_ref(), validate_normalized_string)
44            .add_field_option("url", self.url.as_ref(), validate_uri)
45            .add_struct_option("author", self.author.as_ref(), version)
46            .add_struct_option("committer", self.committer.as_ref(), version)
47            .add_field_option("message", self.message.as_ref(), validate_normalized_string)
48            .into()
49    }
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, Hash)]
53pub struct Commits(pub Vec<Commit>);
54
55impl Validate for Commits {
56    fn validate_version(&self, version: SpecVersion) -> ValidationResult {
57        ValidationContext::new()
58            .add_list("inner", &self.0, |commit| commit.validate_version(version))
59            .into()
60    }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, Hash)]
64pub struct Diff {
65    pub text: Option<AttachedText>,
66    pub url: Option<Uri>,
67}
68
69impl Validate for Diff {
70    fn validate_version(&self, version: SpecVersion) -> ValidationResult {
71        ValidationContext::new()
72            .add_struct_option("text", self.text.as_ref(), version)
73            .add_field_option("url", self.url.as_ref(), validate_uri)
74            .into()
75    }
76}
77
78#[derive(Clone, Debug, PartialEq, Eq, Hash)]
79pub struct IdentifiableAction {
80    pub timestamp: Option<DateTime>,
81    pub name: Option<NormalizedString>,
82    pub email: Option<NormalizedString>,
83}
84
85impl Validate for IdentifiableAction {
86    fn validate_version(&self, _version: SpecVersion) -> ValidationResult {
87        ValidationContext::new()
88            .add_field_option("timestamp", self.timestamp.as_ref(), validate_date_time)
89            .add_field_option("name", self.name.as_ref(), validate_normalized_string)
90            .add_field_option("email", self.email.as_ref(), validate_normalized_string)
91            .into()
92    }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Hash)]
96pub struct Issue {
97    pub issue_type: IssueClassification,
98    pub id: Option<NormalizedString>,
99    pub name: Option<NormalizedString>,
100    pub description: Option<NormalizedString>,
101    pub source: Option<Source>,
102    pub references: Option<Vec<Uri>>,
103}
104
105impl Validate for Issue {
106    fn validate_version(&self, version: SpecVersion) -> ValidationResult {
107        ValidationContext::new()
108            .add_field(
109                "issue_type",
110                &self.issue_type,
111                validate_issue_classification,
112            )
113            .add_field_option("id", self.id.as_ref(), validate_normalized_string)
114            .add_field_option("name", self.name.as_ref(), validate_normalized_string)
115            .add_field_option(
116                "description",
117                self.description.as_ref(),
118                validate_normalized_string,
119            )
120            .add_struct_option("source", self.source.as_ref(), version)
121            .add_list_option("references", self.references.as_ref(), validate_uri)
122            .into()
123    }
124}
125
126pub fn validate_issue_classification(
127    classification: &IssueClassification,
128) -> Result<(), ValidationError> {
129    if matches!(
130        classification,
131        IssueClassification::UnknownIssueClassification(_)
132    ) {
133        return Err(ValidationError::new("Unknown issue classification"));
134    }
135    Ok(())
136}
137
138#[derive(Clone, Debug, PartialEq, Eq, strum::Display, Hash)]
139#[strum(serialize_all = "snake_case")]
140pub enum IssueClassification {
141    Defect,
142    Enhancement,
143    Security,
144    #[doc(hidden)]
145    #[strum(default)]
146    UnknownIssueClassification(String),
147}
148
149impl IssueClassification {
150    pub fn new_unchecked<A: AsRef<str>>(value: A) -> Self {
151        match value.as_ref() {
152            "defect" => Self::Defect,
153            "enhancement" => Self::Enhancement,
154            "security" => Self::Security,
155            unknown => Self::UnknownIssueClassification(unknown.to_string()),
156        }
157    }
158}
159
160#[derive(Clone, Debug, PartialEq, Eq, Hash)]
161pub struct Patch {
162    pub patch_type: PatchClassification,
163    pub diff: Option<Diff>,
164    pub resolves: Option<Vec<Issue>>,
165}
166
167impl Validate for Patch {
168    fn validate_version(&self, version: SpecVersion) -> ValidationResult {
169        ValidationContext::new()
170            .add_enum(
171                "patch_type",
172                &self.patch_type,
173                validate_patch_classification,
174            )
175            .add_struct_option("diff", self.diff.as_ref(), version)
176            .add_list_option("resolves", self.resolves.as_ref(), |issue| {
177                issue.validate_version(version)
178            })
179            .into()
180    }
181}
182
183#[derive(Clone, Debug, PartialEq, Eq, Hash)]
184pub struct Patches(pub Vec<Patch>);
185
186impl Validate for Patches {
187    fn validate_version(&self, version: SpecVersion) -> ValidationResult {
188        ValidationContext::new()
189            .add_list("inner", &self.0, |patch| patch.validate_version(version))
190            .into()
191    }
192}
193
194pub fn validate_patch_classification(
195    classification: &PatchClassification,
196) -> Result<(), ValidationError> {
197    if matches!(
198        classification,
199        PatchClassification::UnknownPatchClassification(_)
200    ) {
201        return Err("Unknown patch classification".into());
202    }
203    Ok(())
204}
205
206#[derive(Clone, Debug, PartialEq, Eq, strum::Display, Hash)]
207#[strum(serialize_all = "kebab-case")]
208pub enum PatchClassification {
209    Unofficial,
210    Monkey,
211    Backport,
212    CherryPick,
213    #[doc(hidden)]
214    #[strum(default)]
215    UnknownPatchClassification(String),
216}
217
218impl PatchClassification {
219    pub fn new_unchecked<A: AsRef<str>>(value: A) -> Self {
220        match value.as_ref() {
221            "unofficial" => Self::Unofficial,
222            "monkey" => Self::Monkey,
223            "backport" => Self::Backport,
224            "cherry-pick" => Self::CherryPick,
225            unknown => Self::UnknownPatchClassification(unknown.to_string()),
226        }
227    }
228}
229
230#[derive(Clone, Debug, PartialEq, Eq, Hash)]
231pub struct Source {
232    pub name: Option<NormalizedString>,
233    pub url: Option<Uri>,
234}
235
236impl Validate for Source {
237    fn validate_version(&self, _version: SpecVersion) -> ValidationResult {
238        ValidationContext::new()
239            .add_field_option("name", self.name.as_ref(), validate_normalized_string)
240            .add_field_option("url", self.url.as_ref(), validate_uri)
241            .into()
242    }
243}
244
245#[cfg(test)]
246mod test {
247    use crate::validation;
248
249    use super::*;
250    use pretty_assertions::assert_eq;
251
252    #[test]
253    fn valid_commits_should_pass_validation() {
254        let validation_result = Commits(vec![Commit {
255            uid: Some(NormalizedString("no_whitespace".to_string())),
256            url: Some(Uri("https://www.example.com".to_string())),
257            author: Some(IdentifiableAction {
258                timestamp: Some(DateTime("1969-06-28T01:20:00.00-04:00".to_string())),
259                name: Some(NormalizedString("Name".to_string())),
260                email: Some(NormalizedString("email@example.com".to_string())),
261            }),
262            committer: Some(IdentifiableAction {
263                timestamp: Some(DateTime("1969-06-28T01:20:00.00-04:00".to_string())),
264                name: Some(NormalizedString("Name".to_string())),
265                email: Some(NormalizedString("email@example.com".to_string())),
266            }),
267            message: Some(NormalizedString("no_whitespace".to_string())),
268        }])
269        .validate();
270
271        assert!(validation_result.passed());
272    }
273
274    #[test]
275    fn invalid_commits_should_fail_validation() {
276        let validation_result = Commits(vec![Commit {
277            uid: Some(NormalizedString("spaces and\ttabs".to_string())),
278            url: Some(Uri("invalid uri".to_string())),
279            author: Some(IdentifiableAction {
280                timestamp: Some(DateTime("Thursday".to_string())),
281                name: Some(NormalizedString("spaces and\ttabs".to_string())),
282                email: Some(NormalizedString("spaces and\ttabs".to_string())),
283            }),
284            committer: Some(IdentifiableAction {
285                timestamp: Some(DateTime("1970-01-01".to_string())),
286                name: Some(NormalizedString("spaces and\ttabs".to_string())),
287                email: Some(NormalizedString("spaces and\ttabs".to_string())),
288            }),
289            message: Some(NormalizedString("spaces and\ttabs".to_string())),
290        }])
291        .validate();
292
293        assert_eq!(
294            validation_result,
295            validation::list(
296                "inner",
297                [(
298                    0,
299                    vec![
300                        validation::field(
301                            "uid",
302                            "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"
303                        ),
304                        validation::field("url", "Uri does not conform to RFC 3986"),
305                        validation::r#struct(
306                            "author",
307                            vec![
308                                validation::field("timestamp", "DateTime does not conform to ISO 8601"),
309                                validation::field("name", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
310                                validation::field("email", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n")
311                            ]
312                        ),
313                        validation::r#struct(
314                            "committer",
315                            vec![
316                                validation::field("timestamp", "DateTime does not conform to ISO 8601"),
317                                validation::field("name", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
318                                validation::field("email", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
319                            ]
320                        ),
321                        validation::field("message", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n")
322                    ]
323                )]
324            )
325        );
326    }
327
328    #[test]
329    fn valid_patches_should_pass_validation() {
330        let validation_result = Patches(vec![Patch {
331            patch_type: PatchClassification::Backport,
332            diff: Some(Diff {
333                text: Some(AttachedText {
334                    content_type: None,
335                    encoding: None,
336                    content: "content".to_string(),
337                }),
338                url: Some(Uri("https://www.example.com".to_string())),
339            }),
340            resolves: Some(vec![Issue {
341                issue_type: IssueClassification::Defect,
342                id: Some(NormalizedString("issue_id".to_string())),
343                name: Some(NormalizedString("issue_name".to_string())),
344                description: Some(NormalizedString("issue_description".to_string())),
345                source: Some(Source {
346                    name: Some(NormalizedString("source_name".to_string())),
347                    url: Some(Uri("https://example.com".to_string())),
348                }),
349                references: Some(vec![Uri("https://example.com".to_string())]),
350            }]),
351        }])
352        .validate();
353
354        assert!(validation_result.passed());
355    }
356
357    #[test]
358    fn invalid_patches_should_fail_validation() {
359        let validation_result = Patches(vec![Patch {
360            patch_type: PatchClassification::UnknownPatchClassification("unknown".to_string()),
361            diff: Some(Diff {
362                text: Some(AttachedText {
363                    content_type: Some(NormalizedString("spaces and \ttabs".to_string())),
364                    encoding: None,
365                    content: "content".to_string(),
366                }),
367                url: Some(Uri("invalid uri".to_string())),
368            }),
369            resolves: Some(vec![Issue {
370                issue_type: IssueClassification::UnknownIssueClassification("unknown".to_string()),
371                id: Some(NormalizedString("spaces and \ttabs".to_string())),
372                name: Some(NormalizedString("spaces and \ttabs".to_string())),
373                description: Some(NormalizedString("spaces and \ttabs".to_string())),
374                source: Some(Source {
375                    name: Some(NormalizedString("spaces and \ttabs".to_string())),
376                    url: Some(Uri("invalid uri".to_string())),
377                }),
378                references: Some(vec![Uri("invalid uri".to_string())]),
379            }]),
380        }])
381        .validate();
382
383        assert_eq!(
384            validation_result,
385            validation::list(
386                "inner",
387                [(
388                    0,
389                    vec![
390                        validation::r#enum("patch_type", "Unknown patch classification"),
391                        validation::r#struct(
392                            "diff",
393                            vec![
394                                validation::r#struct(
395                                    "text",
396                                    vec![
397                                        validation::field(
398                                            "content_type",
399                                            "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"
400                                        )
401                                    ]
402                                ),
403                                validation::field("url", "Uri does not conform to RFC 3986")
404                            ]
405                        ),
406                        validation::list(
407                            "resolves",
408                            [(
409                                0,
410                                vec![
411                                    validation::field("issue_type", "Unknown issue classification"),
412                                    validation::field("id", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
413                                    validation::field("name", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
414                                    validation::field("description", "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n"),
415                                    validation::r#struct(
416                                        "source",
417                                        vec![
418                                            validation::field(
419                                                "name",
420                                                "NormalizedString contains invalid characters \\r \\n \\t or \\r\\n",
421                                            ),
422                                            validation::field(
423                                                "url",
424                                                "Uri does not conform to RFC 3986",
425                                            )
426                                        ]
427                                    ),
428                                    validation::list("references", [(0, validation::custom("", ["Uri does not conform to RFC 3986"]))])
429                                ]
430                            )]
431                        )
432                    ]
433                )]
434            )
435        );
436    }
437}