fuchsia_triage/
config.rs

1// Copyright 2020 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use crate::act::{validate_action, Actions, ActionsSchema, Severity};
6use crate::metrics::fetch::{InspectFetcher, KeyValueFetcher, SelectorString, TextFetcher};
7use crate::metrics::{Metric, Metrics, ValueSource};
8use crate::validate::{validate, Trials, TrialsSchema};
9use crate::Action;
10use anyhow::{bail, format_err, Context, Error};
11use num_derive::FromPrimitive;
12use serde::{Deserialize, Deserializer};
13use std::collections::HashMap;
14
15// These numbers are used in the wasm-bindgen bridge so they are explicit and
16// permanent. They don't need to be sequential. This enum must be consistent
17// with the Source enum in //src/diagnostics/lib/triage/wasm/src/lib.rs.
18#[derive(Debug, Clone, Copy, FromPrimitive, PartialEq)]
19pub enum Source {
20    Inspect = 0,
21    Klog = 1,
22    Syslog = 2,
23    Bootlog = 3,
24    Annotations = 4,
25}
26
27/// Schema for JSON triage configuration. This structure is parsed directly from the configuration
28/// files using serde_json.
29#[derive(Deserialize, Default, Debug)]
30#[serde(deny_unknown_fields)]
31pub struct ConfigFileSchema {
32    /// Map of named Selectors. Each Selector selects a value from Diagnostic data.
33    #[serde(rename = "select")]
34    pub file_selectors: Option<HashMap<String, SelectorEntry>>,
35    /// Map of named Evals. Each Eval calculates a value.
36    #[serde(rename = "eval")]
37    pub file_evals: Option<HashMap<String, String>>,
38    /// Map of named Actions. Each Action uses a boolean value to trigger a warning.
39    #[serde(rename = "act")]
40    pub(crate) file_actions: Option<HashMap<String, ActionConfig>>,
41    /// Map of named Tests. Each test applies sample data to lists of actions that should or
42    /// should not trigger.
43    #[serde(rename = "test")]
44    pub file_tests: Option<TrialsSchema>,
45}
46
47/// A selector entry in the configuration file is either a single string
48/// or a vector of string selectors. Either case is converted to a vector
49/// with at least one element.
50#[derive(Debug)]
51pub struct SelectorEntry(Vec<String>);
52
53impl<'de> Deserialize<'de> for SelectorEntry {
54    fn deserialize<D>(d: D) -> Result<Self, D::Error>
55    where
56        D: Deserializer<'de>,
57    {
58        struct SelectorVec(std::marker::PhantomData<Vec<String>>);
59
60        impl<'de> serde::de::Visitor<'de> for SelectorVec {
61            type Value = Vec<String>;
62
63            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64                f.write_str("either a single selector or an array of selectors")
65            }
66
67            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
68            where
69                E: serde::de::Error,
70            {
71                Ok(vec![value.to_string()])
72            }
73
74            fn visit_seq<A>(self, mut value: A) -> Result<Self::Value, A::Error>
75            where
76                A: serde::de::SeqAccess<'de>,
77            {
78                let mut out = vec![];
79                while let Some(s) = value.next_element::<String>()? {
80                    out.push(s);
81                }
82                if out.is_empty() {
83                    use serde::de::Error;
84                    Err(A::Error::invalid_length(0, &"expected at least one selector"))
85                } else {
86                    Ok(out)
87                }
88            }
89        }
90
91        Ok(SelectorEntry(d.deserialize_any(SelectorVec(std::marker::PhantomData))?))
92    }
93}
94
95// TODO(https://fxbug.dev/42063223): This struct should be removed once serde_json5 has DeserializeSeed support.
96#[derive(Deserialize, Debug)]
97#[serde(tag = "type")]
98pub enum ActionConfig {
99    Alert {
100        trigger: String,
101        print: String,
102        file_bug: Option<String>,
103        tag: Option<String>,
104        severity: Severity,
105    },
106    Warning {
107        trigger: String,
108        print: String,
109        file_bug: Option<String>,
110        tag: Option<String>,
111    },
112    Gauge {
113        value: String,
114        format: Option<String>,
115        tag: Option<String>,
116    },
117    Snapshot {
118        trigger: String,
119        repeat: String,
120        signature: String,
121    },
122}
123
124impl ConfigFileSchema {
125    fn try_from_str_with_namespace(s: &str, namespace: &str) -> Result<Self, anyhow::Error> {
126        let schema = serde_json5::from_str::<ConfigFileSchema>(s)
127            .map_err(|e| format_err!("Unable to deserialize config file {}", e))?;
128        validate_config(&schema, namespace)?;
129        Ok(schema)
130    }
131}
132
133impl TryFrom<String> for ConfigFileSchema {
134    type Error = anyhow::Error;
135
136    fn try_from(s: String) -> Result<Self, Self::Error> {
137        ConfigFileSchema::try_from_str_with_namespace(&s, /* namespace=*/ "")
138    }
139}
140
141fn validate_config(config: &ConfigFileSchema, namespace: &str) -> Result<(), Error> {
142    if let Some(ref actions_config) = config.file_actions {
143        for (action_name, action_config) in actions_config.iter() {
144            validate_action(action_name, action_config, namespace)?;
145        }
146    }
147    Ok(())
148}
149
150pub enum DataFetcher {
151    Inspect(InspectFetcher),
152    Text(TextFetcher),
153    KeyValue(KeyValueFetcher),
154    None,
155}
156
157/// The path of the Diagnostic files and the data contained within them.
158pub struct DiagnosticData {
159    pub name: String,
160    pub source: Source,
161    pub data: DataFetcher,
162}
163
164impl DiagnosticData {
165    pub fn new(name: String, source: Source, contents: String) -> Result<DiagnosticData, Error> {
166        let data = match source {
167            Source::Inspect => DataFetcher::Inspect(
168                InspectFetcher::try_from(&*contents).context("Parsing inspect.json")?,
169            ),
170            Source::Syslog | Source::Klog | Source::Bootlog => {
171                DataFetcher::Text(TextFetcher::from(&*contents))
172            }
173            Source::Annotations => DataFetcher::KeyValue(
174                KeyValueFetcher::try_from(&*contents).context("Parsing annotations")?,
175            ),
176        };
177        Ok(DiagnosticData { name, source, data })
178    }
179
180    pub fn new_empty(name: String, source: Source) -> DiagnosticData {
181        DiagnosticData { name, source, data: DataFetcher::None }
182    }
183}
184
185pub struct ParseResult {
186    pub metrics: Metrics,
187    pub(crate) actions: Actions,
188    pub tests: Trials,
189}
190
191impl ParseResult {
192    pub fn new(
193        configs: &HashMap<String, String>,
194        action_tag_directive: &ActionTagDirective,
195    ) -> Result<ParseResult, Error> {
196        let mut actions = HashMap::new();
197        let mut metrics = HashMap::new();
198        let mut tests = HashMap::new();
199
200        for (namespace, file_data) in configs {
201            let file_config =
202                match ConfigFileSchema::try_from_str_with_namespace(file_data, namespace) {
203                    Ok(c) => c,
204                    Err(e) => bail!("Parsing file '{}': {}", namespace, e),
205                };
206            let ConfigFileSchema { file_actions, file_selectors, file_evals, file_tests } =
207                file_config;
208            // Other code assumes that each name will have an entry in all categories.
209            let file_actions_config = file_actions.unwrap_or_else(HashMap::new);
210            let file_actions = file_actions_config
211                .into_iter()
212                .map(|(k, action_config)| {
213                    Action::from_config_with_namespace(action_config, namespace)
214                        .map(|action| (k, action))
215                })
216                .collect::<Result<HashMap<_, _>, _>>()?;
217            let file_selectors = file_selectors.unwrap_or_else(HashMap::new);
218            let file_evals = file_evals.unwrap_or_else(HashMap::new);
219            let file_tests = file_tests.unwrap_or_else(HashMap::new);
220            let file_actions = filter_actions(file_actions, action_tag_directive);
221            let mut file_metrics = HashMap::new();
222            for (key, value) in file_selectors.into_iter() {
223                let mut selectors = vec![];
224                for v in value.0 {
225                    selectors.push(SelectorString::try_from(v)?);
226                }
227                file_metrics.insert(key, ValueSource::new(Metric::Selector(selectors)));
228            }
229            for (key, value) in file_evals.into_iter() {
230                if file_metrics.contains_key(&key) {
231                    bail!("Duplicate metric name {} in file {}", key, namespace);
232                }
233                file_metrics.insert(
234                    key,
235                    ValueSource::try_from_expression_with_namespace(&value, namespace)?,
236                );
237            }
238            metrics.insert(namespace.clone(), file_metrics);
239            actions.insert(namespace.clone(), file_actions);
240            tests.insert(namespace.clone(), file_tests);
241        }
242
243        Ok(ParseResult { actions, metrics, tests })
244    }
245
246    pub fn all_selectors(&self) -> Vec<String> {
247        let mut result = Vec::new();
248        for (_, metric_set) in self.metrics.iter() {
249            for (_, value_source) in metric_set.iter() {
250                if let Metric::Selector(selectors) = &value_source.metric {
251                    for selector in selectors {
252                        result.push(selector.full_selector.to_owned());
253                    }
254                }
255            }
256        }
257        result
258    }
259
260    pub fn validate(&self) -> Result<(), Error> {
261        validate(self)
262    }
263
264    pub fn reset_state(&self) {
265        for (_, metric_set) in self.metrics.iter() {
266            for (_, value_source) in metric_set.iter() {
267                *value_source.cached_value.borrow_mut() = None;
268            }
269        }
270
271        for (_, action_set) in self.actions.iter() {
272            for (_, action) in action_set.iter() {
273                match action {
274                    Action::Alert(alert) => {
275                        *alert.trigger.cached_value.borrow_mut() = None;
276                    }
277                    Action::Gauge(gauge) => {
278                        *gauge.value.cached_value.borrow_mut() = None;
279                    }
280                    Action::Snapshot(snapshot) => {
281                        *snapshot.trigger.cached_value.borrow_mut() = None;
282                        *snapshot.repeat.cached_value.borrow_mut() = None;
283                    }
284                }
285            }
286        }
287    }
288}
289
290/// A value which directs how to include Actions based on their tags.
291pub enum ActionTagDirective {
292    /// Include all of the Actions in the Config
293    AllowAll,
294
295    /// Only include the Actions which match the given tags
296    Include(Vec<String>),
297
298    /// Include all tags excluding the given tags
299    Exclude(Vec<String>),
300}
301
302impl ActionTagDirective {
303    /// Creates a new ActionTagDirective based on the following rules,
304    ///
305    /// - AllowAll iff tags is empty and exclude_tags is empty.
306    /// - Include if tags is not empty and exclude_tags is empty.
307    /// - Include if tags is not empty and exclude_tags is not empty, in this
308    ///   situation the exclude_ags will be ignored since include implies excluding
309    ///   all other tags.
310    /// - Exclude iff tags is empty and exclude_tags is not empty.
311    pub fn from_tags(tags: Vec<String>, exclude_tags: Vec<String>) -> ActionTagDirective {
312        match (tags.is_empty(), exclude_tags.is_empty()) {
313            // tags are not empty
314            (false, _) => ActionTagDirective::Include(tags),
315            // tags are empty, exclude_tags are not empty
316            (true, false) => ActionTagDirective::Exclude(exclude_tags),
317            _ => ActionTagDirective::AllowAll,
318        }
319    }
320}
321
322/// Exfiltrates the actions in the ActionsSchema.
323///
324/// This method will enumerate the actions in the ActionsSchema and determine
325/// which Actions are included based on the directive. Actions only contain a
326/// single tag so an Include directive implies that all other tags should be
327/// excluded and an Exclude directive implies that all other tags should be
328/// included.
329pub(crate) fn filter_actions(
330    actions: ActionsSchema,
331    action_directive: &ActionTagDirective,
332) -> ActionsSchema {
333    match action_directive {
334        ActionTagDirective::AllowAll => actions,
335        ActionTagDirective::Include(tags) => actions
336            .into_iter()
337            .filter(|(_, a)| match &a.get_tag() {
338                Some(tag) => tags.contains(tag),
339                None => false,
340            })
341            .collect(),
342        ActionTagDirective::Exclude(tags) => actions
343            .into_iter()
344            .filter(|(_, a)| match &a.get_tag() {
345                Some(tag) => !tags.contains(tag),
346                None => true,
347            })
348            .collect(),
349    }
350}
351
352#[cfg(test)]
353mod test {
354    use super::*;
355    use crate::act::Alert;
356    use fidl_fuchsia_feedback::MAX_CRASH_SIGNATURE_LENGTH;
357    use maplit::hashmap;
358
359    // initialize() will be tested in the integration test: "fx test triage_lib_test"
360    // TODO(cphoenix) - set up dirs under test/ and test initialize() here.
361
362    #[fuchsia::test]
363    fn inspect_data_from_works() -> Result<(), Error> {
364        assert!(InspectFetcher::try_from("foo").is_err(), "'foo' isn't valid JSON");
365        assert!(InspectFetcher::try_from(r#"{"a":5}"#).is_err(), "Needed an array");
366        assert!(InspectFetcher::try_from("[]").is_ok(), "A JSON array should have worked");
367        Ok(())
368    }
369
370    #[fuchsia::test]
371    fn action_tag_directive_from_tags_allow_all() {
372        let result = ActionTagDirective::from_tags(vec![], vec![]);
373        match result {
374            ActionTagDirective::AllowAll => (),
375            _ => panic!("failed to create correct ActionTagDirective"),
376        }
377    }
378
379    #[fuchsia::test]
380    fn action_tag_directive_from_tags_include() {
381        let result =
382            ActionTagDirective::from_tags(vec!["t1".to_string(), "t2".to_string()], vec![]);
383        match result {
384            ActionTagDirective::Include(tags) => {
385                assert_eq!(tags, vec!["t1".to_string(), "t2".to_string()])
386            }
387            _ => panic!("failed to create correct ActionTagDirective"),
388        }
389    }
390
391    #[fuchsia::test]
392    fn action_tag_directive_from_tags_include_override_exclude() {
393        let result = ActionTagDirective::from_tags(
394            vec!["t1".to_string(), "t2".to_string()],
395            vec!["t3".to_string()],
396        );
397        match result {
398            ActionTagDirective::Include(tags) => {
399                assert_eq!(tags, vec!["t1".to_string(), "t2".to_string()])
400            }
401            _ => panic!("failed to create correct ActionTagDirective"),
402        }
403    }
404
405    #[fuchsia::test]
406    fn action_tag_directive_from_tags_exclude() {
407        let result =
408            ActionTagDirective::from_tags(vec![], vec!["t1".to_string(), "t2".to_string()]);
409        match result {
410            ActionTagDirective::Exclude(tags) => {
411                assert_eq!(tags, vec!["t1".to_string(), "t2".to_string()])
412            }
413            _ => panic!("failed to create correct ActionTagDirective"),
414        }
415    }
416
417    // helper macro to create an ActionsSchema
418    macro_rules! actions_schema {
419        ( $($key:expr => $contents:expr, $tag:expr),+ ) => {
420            {
421                let mut m =  ActionsSchema::new();
422                let trigger =  ValueSource::try_from_expression_with_default_namespace("a_trigger").unwrap();
423
424                $(
425                    let action = Action::Alert(Alert {
426                        trigger: trigger.clone(),
427                        print: $contents.to_string(),
428                        tag: $tag,
429                        file_bug: None,
430                        severity: Severity::Warning,
431                    });
432                    m.insert($key.to_string(), action);
433                )+
434                m
435            }
436        }
437    }
438
439    // helper macro to create an ActionsSchema
440    macro_rules! assert_has_action {
441        ($result:expr, $key:expr, $contents:expr) => {
442            match $result.get(&$key.to_string()) {
443                Some(Action::Alert(a)) => {
444                    assert_eq!(a.print, $contents.to_string());
445                }
446                _ => {
447                    assert!(false);
448                }
449            }
450        };
451    }
452
453    #[fuchsia::test]
454    fn filter_actions_allow_all() {
455        let result = filter_actions(
456            actions_schema! {
457                "no_tag" => "foo", None,
458                "tagged" => "bar", Some("tag".to_string())
459            },
460            &ActionTagDirective::AllowAll,
461        );
462        assert_eq!(result.len(), 2);
463    }
464
465    #[fuchsia::test]
466    fn filter_actions_include_one_tag() {
467        let result = filter_actions(
468            actions_schema! {
469                "1" => "p1", Some("ignore".to_string()),
470                "2" => "p2", Some("tag".to_string()),
471                "3" => "p3", Some("tag".to_string())
472            },
473            &ActionTagDirective::Include(vec!["tag".to_string()]),
474        );
475        assert_eq!(result.len(), 2);
476        assert_has_action!(result, "2", "p2");
477        assert_has_action!(result, "3", "p3");
478    }
479
480    #[fuchsia::test]
481    fn filter_actions_include_many_tags() {
482        let result = filter_actions(
483            actions_schema! {
484                "1" => "p1", Some("ignore".to_string()),
485                "2" => "p2", Some("tag1".to_string()),
486                "3" => "p3", Some("tag2".to_string()),
487                "4" => "p4", Some("tag2".to_string())
488            },
489            &ActionTagDirective::Include(vec!["tag1".to_string(), "tag2".to_string()]),
490        );
491        assert_eq!(result.len(), 3);
492        assert_has_action!(result, "2", "p2");
493        assert_has_action!(result, "3", "p3");
494        assert_has_action!(result, "4", "p4");
495    }
496
497    #[fuchsia::test]
498    fn filter_actions_exclude_one_tag() {
499        let result = filter_actions(
500            actions_schema! {
501                "1" => "p1", Some("ignore".to_string()),
502                "2" => "p2", Some("tag".to_string()),
503                "3" => "p3", Some("tag".to_string())
504            },
505            &ActionTagDirective::Exclude(vec!["tag".to_string()]),
506        );
507        assert_eq!(result.len(), 1);
508        assert_has_action!(result, "1", "p1");
509    }
510
511    #[fuchsia::test]
512    fn filter_actions_exclude_many() {
513        let result = filter_actions(
514            actions_schema! {
515                "1" => "p1", Some("ignore".to_string()),
516                "2" => "p2", Some("tag1".to_string()),
517                "3" => "p3", Some("tag2".to_string()),
518                "4" => "p4", Some("tag2".to_string())
519            },
520            &ActionTagDirective::Exclude(vec!["tag1".to_string(), "tag2".to_string()]),
521        );
522        assert_eq!(result.len(), 1);
523        assert_has_action!(result, "1", "p1");
524    }
525
526    #[fuchsia::test]
527    fn filter_actions_include_does_not_include_empty_tag() {
528        let result = filter_actions(
529            actions_schema! {
530                "1" => "p1", None,
531                "2" => "p2", Some("tag".to_string())
532            },
533            &ActionTagDirective::Include(vec!["tag".to_string()]),
534        );
535        assert_eq!(result.len(), 1);
536        assert_has_action!(result, "2", "p2");
537    }
538
539    #[fuchsia::test]
540    fn filter_actions_exclude_does_include_empty_tag() {
541        let result = filter_actions(
542            actions_schema! {
543                "1" => "p1", None,
544                "2" => "p2", Some("tag".to_string())
545            },
546            &ActionTagDirective::Exclude(vec!["tag".to_string()]),
547        );
548        assert_eq!(result.len(), 1);
549        assert_has_action!(result, "1", "p1");
550    }
551
552    #[fuchsia::test]
553    fn select_section_parsing() {
554        let config_result = ConfigFileSchema::try_from(
555            r#"
556        {
557            select: {
558                a: ["INSPECT:core:root:prop"],
559                b: "INSPECT:core:root:prop",
560                c: ["INSPECT:core:root:prop",
561                    "INSPECT:core:root:prop2",
562                    "INSPECT:core:root:prop3"],
563            }
564        }
565        "#
566            .to_string(),
567        );
568        assert_eq!(
569            3,
570            config_result.expect("parse json").file_selectors.expect("has selectors").len()
571        );
572
573        let config_result = ConfigFileSchema::try_from(
574            r#"
575        {
576            select: {
577                a: ["INSPECT:core:root:prop", 1],
578            }
579        }
580        "#
581            .to_string(),
582        );
583        assert!(format!("{}", config_result.expect_err("parsing should fail"))
584            .contains("expected a string"));
585
586        let config_result = ConfigFileSchema::try_from(
587            r#"
588        {
589            select: {
590                a: [],
591            }
592        }
593        "#
594            .to_string(),
595        );
596        assert!(format!("{}", config_result.expect_err("parsing should fail"))
597            .contains("expected at least one selector"));
598    }
599
600    #[fuchsia::test]
601    fn all_selectors_works() {
602        macro_rules! s {
603            ($s:expr) => {
604                $s.to_string()
605            };
606        }
607        let file_map = hashmap![
608            s!("file1") => s!(r#"{ select: {selector1: "INSPECT:name:path:label"}}"#),
609            s!("file2") =>
610                s!(r#"
611                    { select: {
612                        selector1: "INSPECT:word:stuff:identifier",
613                        selector2: ["INSPECT:a:b:c", "INSPECT:d:e:f"],
614                      },
615                      eval: {e: "2+2"} }"#),
616        ];
617        let parse = ParseResult::new(&file_map, &ActionTagDirective::AllowAll).unwrap();
618        let selectors = parse.all_selectors();
619        assert_eq!(selectors.len(), 4);
620        assert!(selectors.contains(&s!("INSPECT:name:path:label")));
621        assert!(selectors.contains(&s!("INSPECT:word:stuff:identifier")));
622        assert!(selectors.contains(&s!("INSPECT:a:b:c")));
623        assert!(selectors.contains(&s!("INSPECT:d:e:f")));
624        // Internal logic test: Make sure we're not returning eval entries.
625        assert!(!selectors.contains(&s!("2+2")));
626    }
627
628    #[fuchsia::test]
629    fn too_long_signature_rejected() {
630        macro_rules! s {
631            ($s:expr) => {
632                $s.to_string()
633            };
634        }
635        // {:a<1$} means pad the interpolated arg with "a" to N chars, where N is from parameter 1.
636        let signature_ok_config = format!(
637            r#"{{
638                act: {{ foo: {{
639                    type: "Snapshot",
640                    repeat: "1",
641                    trigger: "1>0",
642                    signature: "{:a<1$}",
643                }} }}
644            }} "#,
645            "", // Empty string for "aaaa..." padding
646            MAX_CRASH_SIGNATURE_LENGTH as usize
647        );
648        let signature_too_long_config = format!(
649            r#"{{
650                act: {{ foo: {{
651                    type: "Snapshot",
652                    repeat: "1",
653                    trigger: "1>0",
654                    signature: "{:a<1$}",
655                }} }}
656            }} "#,
657            "", // Empty string for "aaaa..." padding
658            MAX_CRASH_SIGNATURE_LENGTH as usize + 1
659        );
660        let file_map_ok = hashmap![s!("file") => signature_ok_config];
661        let file_map_err = hashmap![s!("file") => signature_too_long_config];
662        assert!(ParseResult::new(&file_map_ok, &ActionTagDirective::AllowAll).is_ok());
663        assert!(ParseResult::new(&file_map_err, &ActionTagDirective::AllowAll).is_err());
664    }
665}