persistence_config/
lib.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.
4use anyhow::{Error, bail};
5use glob::glob;
6use regex::Regex;
7use serde::Serialize;
8use serde_derive::Deserialize;
9use std::borrow::Borrow;
10use std::collections::HashMap;
11use std::fmt::Display;
12use std::ops::Deref;
13use std::sync::LazyLock;
14
15/// The outer map is service_name; the inner is tag.
16pub type Config = HashMap<ServiceName, HashMap<Tag, TagConfig>>;
17
18/// Schema for config-file entries. Each config file is a JSON array of these.
19#[derive(Deserialize, Default, Debug, PartialEq)]
20#[cfg_attr(test, derive(Clone))]
21#[serde(deny_unknown_fields)]
22struct TaggedPersist {
23    /// The Inspect data defined here will be published under this tag.
24    /// Tags must not be duplicated within a service, even between files.
25    /// Tags must conform to /[a-z][a-z-]*/.
26    tag: String,
27    /// Tags are organized under a named service. Multiple tags can use the same
28    /// service name. Service names must conform to /[a-z][a-z-]*/.
29    service_name: String,
30    #[serde(flatten)]
31    tag_config: TagConfig,
32}
33
34/// Configuration for a single tag for a single service.
35///
36/// See [`TaggedPersist`] for the meaning of corresponding fields.
37#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
38pub struct TagConfig {
39    /// These selectors will be fetched and stored for publication on the next boot.
40    #[serde(with = "selectors_ext::inspect")]
41    pub selectors: Vec<fidl_fuchsia_diagnostics::Selector>,
42    /// This is the max size of the file saved, which is the JSON-serialized version
43    /// of the selectors' data.
44    pub max_bytes: usize,
45    /// Persistence requests will be throttled to this. Requests received early will be delayed.
46    pub min_seconds_between_fetch: i64,
47    /// Should this tag persist across multiple reboots?
48    #[serde(default)]
49    pub persist_across_boot: bool,
50}
51
52/// Wrapper class for a valid tag name.
53///
54/// This is a witness class that can only be constructed from a `String` that
55/// matches [`NAME_PATTERN`].
56#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
57pub struct Tag(String);
58
59// Necessary to support the hashbrown::HashMap::entry_ref API.
60impl From<&Tag> for Tag {
61    fn from(value: &Self) -> Self {
62        value.clone()
63    }
64}
65
66/// Wrapper class for a valid service name.
67///
68/// This is a witness class that can only be constructed from a `String` that
69/// matches [`NAME_PATTERN`].
70#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
71pub struct ServiceName(String);
72
73// Necessary to support the hashbrown::HashMap::entry_ref API.
74impl From<&ServiceName> for ServiceName {
75    fn from(value: &Self) -> Self {
76        value.clone()
77    }
78}
79
80/// A regular expression corresponding to a valid tag or service name.
81const NAME_PATTERN: &str = r"^[a-z][a-z-]*$";
82
83static NAME_VALIDATOR: LazyLock<Regex> = LazyLock::new(|| Regex::new(NAME_PATTERN).unwrap());
84
85impl Tag {
86    pub fn new(tag: impl Into<String>) -> Result<Self, Error> {
87        let tag = tag.into();
88        if !NAME_VALIDATOR.is_match(&tag) {
89            bail!("Invalid tag {} must match [a-z][a-z-]*", tag);
90        }
91        Ok(Self(tag))
92    }
93
94    pub fn as_str(&self) -> &str {
95        self.0.as_ref()
96    }
97}
98
99impl ServiceName {
100    pub fn new(name: String) -> Result<Self, Error> {
101        if !NAME_VALIDATOR.is_match(&name) {
102            bail!("Invalid service name {} must match [a-z][a-z-]*", name);
103        }
104        Ok(Self(name))
105    }
106}
107
108/// Allow `Tag` to be treated like a `&str` for display, etc.
109impl Deref for Tag {
110    type Target = str;
111
112    fn deref(&self) -> &Self::Target {
113        self.as_str()
114    }
115}
116
117/// Allow `ServiceName` to be treated like a `&str` for display, etc.
118impl Deref for ServiceName {
119    type Target = str;
120
121    fn deref(&self) -> &Self::Target {
122        let Self(tag) = self;
123        tag
124    }
125}
126
127/// Allow treating `Tag` as a `&str` for, e.g., HashMap indexing operations.
128impl Borrow<str> for Tag {
129    fn borrow(&self) -> &str {
130        self
131    }
132}
133
134/// Allow treating `ServiceName` as a `&str` for, e.g., HashMap indexing
135/// operations.
136impl Borrow<str> for ServiceName {
137    fn borrow(&self) -> &str {
138        self
139    }
140}
141
142impl Display for Tag {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        let Self(name) = self;
145        name.fmt(f)
146    }
147}
148
149impl PartialEq<str> for Tag {
150    fn eq(&self, other: &str) -> bool {
151        self.0 == other
152    }
153}
154
155impl Display for ServiceName {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        let Self(name) = self;
158        name.fmt(f)
159    }
160}
161
162impl From<ServiceName> for String {
163    fn from(ServiceName(value): ServiceName) -> Self {
164        value
165    }
166}
167
168const CONFIG_GLOB: &str = "/config/data/*.persist";
169
170fn try_insert_items(config: &mut Config, config_text: &str) -> Result<(), Error> {
171    let items: Vec<TaggedPersist> = serde_json5::from_str(config_text)?;
172    for item in items {
173        let TaggedPersist { tag, service_name, mut tag_config } = item;
174        let tag = Tag::new(tag)?;
175        let name = ServiceName::new(service_name)?;
176        let mut name_filter = SameTreeNameFilter::default();
177        tag_config.selectors.retain(|s| name_filter.check(s));
178        if let Some(existing) = config.entry(name.clone()).or_default().insert(tag, tag_config) {
179            bail!("Duplicate TagConfig found: {:?}", existing);
180        }
181    }
182    Ok(())
183}
184
185/// A stateful filter that verifies selectors have the same tree name.
186#[derive(Default)]
187struct SameTreeNameFilter {
188    tree_names: Option<Option<fidl_fuchsia_diagnostics::TreeNames>>,
189}
190
191impl SameTreeNameFilter {
192    fn check(&mut self, s: &fidl_fuchsia_diagnostics::Selector) -> bool {
193        let tree_names = match &self.tree_names {
194            Some(names) => names,
195            None => {
196                self.tree_names = Some(s.tree_names.clone());
197                return true;
198            }
199        };
200        match (tree_names, &s.tree_names) {
201            (None, None) => true,
202            (
203                Some(fidl_fuchsia_diagnostics::TreeNames::All(_)),
204                Some(fidl_fuchsia_diagnostics::TreeNames::All(_)),
205            ) => true,
206            (
207                Some(fidl_fuchsia_diagnostics::TreeNames::Some(a)),
208                Some(fidl_fuchsia_diagnostics::TreeNames::Some(b)),
209            ) if a == b => true,
210            _ => {
211                log::warn!(
212                    "Only selectors targeting the same tree are allowed: \"{}\"",
213                    selectors::selector_to_string(s, selectors::SelectorDisplayOptions::default())
214                        .unwrap_or_else(|e| format!("<INVALID: {e}>"))
215                );
216                false
217            }
218        }
219    }
220}
221
222pub fn load_configuration_files() -> Result<Config, Error> {
223    load_configuration_files_from(CONFIG_GLOB)
224}
225
226pub fn load_configuration_files_from(path: &str) -> Result<Config, Error> {
227    let mut config = HashMap::new();
228    for file_path in glob(path)? {
229        try_insert_items(&mut config, &std::fs::read_to_string(file_path?)?)?;
230    }
231    Ok(config)
232}
233
234#[cfg(test)]
235mod test {
236    use assert_matches::assert_matches;
237
238    use super::*;
239    use test_case::test_case;
240
241    #[fuchsia::test]
242    fn verify_insert_logic() {
243        let mut config = HashMap::new();
244        let taga_servab = r#"[
245            {
246                service_name: 'serv-a',
247                tag: 'tag-a',
248                max_bytes: 10,
249                min_seconds_between_fetch: 31,
250                selectors: ['INSPECT:a:b', 'INSPECT:b:c']
251            },
252            {
253                service_name: 'serv-b',
254                tag: 'tag-a',
255                max_bytes: 20,
256                min_seconds_between_fetch: 32,
257                selectors: ['INSPECT:c:d'],
258                persist_across_boot: true
259            }
260        ]"#;
261
262        let tagb_servb = r#"[
263            {
264                service_name: 'serv-b',
265                tag: 'tag-b',
266                max_bytes: 30,
267                min_seconds_between_fetch: 33,
268                selectors: ['INSPECT:d:e']
269            }
270        ]"#;
271
272        assert_matches!(try_insert_items(&mut config, taga_servab), Ok(()));
273        assert_matches!(try_insert_items(&mut config, tagb_servb), Ok(()));
274
275        assert_eq!(
276            config,
277            HashMap::from([
278                (
279                    ServiceName("serv-a".to_string()),
280                    HashMap::from([(
281                        Tag("tag-a".to_string()),
282                        TagConfig {
283                            max_bytes: 10,
284                            min_seconds_between_fetch: 31,
285                            selectors: vec![
286                                selectors::parse_verbose("a:b").unwrap(),
287                                selectors::parse_verbose("b:c").unwrap(),
288                            ],
289                            persist_across_boot: false,
290                        }
291                    )])
292                ),
293                (
294                    ServiceName("serv-b".to_string()),
295                    HashMap::from([
296                        (
297                            Tag("tag-a".to_string()),
298                            TagConfig {
299                                max_bytes: 20,
300                                min_seconds_between_fetch: 32,
301                                selectors: vec![selectors::parse_verbose("c:d").unwrap()],
302                                persist_across_boot: true,
303                            }
304                        ),
305                        (
306                            Tag("tag-b".to_string()),
307                            TagConfig {
308                                max_bytes: 30,
309                                min_seconds_between_fetch: 33,
310                                selectors: vec![selectors::parse_verbose("d:e").unwrap()],
311                                persist_across_boot: false,
312                            }
313                        )
314                    ])
315                )
316            ])
317        );
318
319        // Can't duplicate tags in the same service
320        assert_matches!(try_insert_items(&mut config, tagb_servb), Err(_));
321    }
322
323    #[fuchsia::test]
324    fn test_tag_equals_str() {
325        assert_eq!(&Tag::new("foo").unwrap(), "foo");
326    }
327
328    #[test_case(
329        r#"[{
330            tag: 'tag',
331            service_name: 'bad-service-1',
332            max_bytes: 10,
333            min_seconds_between_fetch: 10,
334            selectors: ['INSPECT:a:b']
335        }]"#
336        ; "numbers_in_name"
337    )]
338    #[test_case(
339        r#"[{
340            tag: 'tag',
341            service_name: 'bad_service',
342            max_bytes: 10,
343            min_seconds_between_fetch: 10,
344            selectors: ['INSPECT:a:b']
345        }]"#
346        ; "underscores_in_name"
347    )]
348    #[test_case(
349        r#"[{
350            tag: 'tag',
351            service_name: 'service',
352            max_bytes: 10,
353            min_seconds_between_fetch: 10,
354            selectors: ['a:b']
355        }]"#
356        ; "selector_source_not_specified"
357    )]
358    #[test_case(
359        r#"[{
360            tag: 'tag',
361            service_name: 'service',
362            max_bytes: 10,
363            min_seconds_between_fetch: 10,
364            selectors: [
365                'INSPECT:a:b'
366                'INSPECT:a:[name=custom_tree]c'
367            ]
368        }]"#
369        ; "different_tree_names"
370    )]
371    #[fuchsia::test]
372    fn rejects_invalid_config(config_text: &str) {
373        let mut config = HashMap::new();
374        assert_matches!(try_insert_items(&mut config, config_text), Err(_));
375    }
376
377    #[test_case(
378        r#"[{
379            tag: 'tag',
380            service_name: 'service',
381            max_bytes: 10,
382            min_seconds_between_fetch: 10,
383            selectors: [
384                'INSPECT:a:[name=custom_tree]b',
385                'INSPECT:a:[name=custom_tree]c'
386            ]
387        }]"#
388        ; "same_custom_tree_names"
389    )]
390    #[fuchsia::test]
391    fn valid_config(config_text: &str) {
392        let mut config = HashMap::new();
393        assert_matches!(try_insert_items(&mut config, config_text), Ok(()));
394    }
395}