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::{bail, Error};
5use glob::glob;
6use regex::Regex;
7use serde_derive::Deserialize;
8use std::borrow::Borrow;
9use std::collections::HashMap;
10use std::fmt::Display;
11use std::ops::Deref;
12use std::sync::LazyLock;
13
14/// The outer map is service_name; the inner is tag.
15pub type Config = HashMap<ServiceName, HashMap<Tag, TagConfig>>;
16
17/// Schema for config-file entries. Each config file is a JSON array of these.
18#[derive(Deserialize, Default, Debug, PartialEq)]
19#[cfg_attr(test, derive(Clone))]
20#[serde(deny_unknown_fields)]
21struct TaggedPersist {
22    /// The Inspect data defined here will be published under this tag.
23    /// Tags must not be duplicated within a service, even between files.
24    /// Tags must conform to /[a-z][a-z-]*/.
25    pub tag: String,
26    /// Each tag will only be requestable via a named service. Multiple tags can use the
27    /// same service name, which will be published and routed as DataPersistence_{service_name}.
28    /// Service names must conform to /[a-z][a-z-]*/.
29    pub service_name: String,
30    /// These selectors will be fetched and stored for publication on the next boot.
31    pub selectors: Vec<String>,
32    /// This is the max size of the file saved, which is the JSON-serialized version
33    /// of the selectors' data.
34    pub max_bytes: usize,
35    /// Persistence requests will be throttled to this. Requests received early will be delayed.
36    pub min_seconds_between_fetch: i64,
37    /// Should this tag persist across multiple reboots?
38    #[serde(default)]
39    pub persist_across_boot: bool,
40}
41
42/// Configuration for a single tag for a single service.
43///
44/// See [`TaggedPersist`] for the meaning of corresponding fields.
45#[derive(Debug, Eq, PartialEq)]
46pub struct TagConfig {
47    pub selectors: Vec<String>,
48    pub max_bytes: usize,
49    pub min_seconds_between_fetch: i64,
50    pub persist_across_boot: bool,
51}
52
53/// Wrapper class for a valid tag name.
54///
55/// This is a witness class that can only be constructed from a `String` that
56/// matches [`NAME_PATTERN`].
57#[derive(Clone, Debug, Eq, Hash, PartialEq)]
58pub struct Tag(String);
59
60/// Wrapper class for a valid service name.
61///
62/// This is a witness class that can only be constructed from a `String` that
63/// matches [`NAME_PATTERN`].
64#[derive(Clone, Debug, Eq, Hash, PartialEq)]
65pub struct ServiceName(String);
66
67/// A regular expression corresponding to a valid tag or service name.
68const NAME_PATTERN: &str = r"^[a-z][a-z-]*$";
69
70static NAME_VALIDATOR: LazyLock<Regex> = LazyLock::new(|| Regex::new(NAME_PATTERN).unwrap());
71
72impl Tag {
73    pub fn new(tag: impl Into<String>) -> Result<Self, Error> {
74        let tag = tag.into();
75        if !NAME_VALIDATOR.is_match(&tag) {
76            bail!("Invalid tag {} must match [a-z][a-z-]*", tag);
77        }
78        Ok(Self(tag))
79    }
80
81    pub fn as_str(&self) -> &str {
82        self.0.as_ref()
83    }
84}
85
86impl ServiceName {
87    pub fn new(name: String) -> Result<Self, Error> {
88        if !NAME_VALIDATOR.is_match(&name) {
89            bail!("Invalid service name {} must match [a-z][a-z-]*", name);
90        }
91        Ok(Self(name))
92    }
93}
94
95/// Allow `Tag` to be treated like a `&str` for display, etc.
96impl Deref for Tag {
97    type Target = str;
98
99    fn deref(&self) -> &Self::Target {
100        self.as_str()
101    }
102}
103
104/// Allow `ServiceName` to be treated like a `&str` for display, etc.
105impl Deref for ServiceName {
106    type Target = str;
107
108    fn deref(&self) -> &Self::Target {
109        let Self(tag) = self;
110        tag
111    }
112}
113
114/// Allow treating `Tag` as a `&str` for, e.g., HashMap indexing operations.
115impl Borrow<str> for Tag {
116    fn borrow(&self) -> &str {
117        self
118    }
119}
120
121/// Allow treating `ServiceName` as a `&str` for, e.g., HashMap indexing
122/// operations.
123impl Borrow<str> for ServiceName {
124    fn borrow(&self) -> &str {
125        self
126    }
127}
128
129impl Display for Tag {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        let Self(name) = self;
132        name.fmt(f)
133    }
134}
135
136impl PartialEq<str> for Tag {
137    fn eq(&self, other: &str) -> bool {
138        self.0 == other
139    }
140}
141
142impl Display for ServiceName {
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 From<ServiceName> for String {
150    fn from(ServiceName(value): ServiceName) -> Self {
151        value
152    }
153}
154
155const CONFIG_GLOB: &str = "/config/data/*.persist";
156
157fn try_insert_items(config: &mut Config, config_text: &str) -> Result<(), Error> {
158    let items: Vec<TaggedPersist> = serde_json5::from_str(config_text)?;
159    for item in items {
160        let TaggedPersist {
161            tag,
162            service_name,
163            selectors,
164            max_bytes,
165            min_seconds_between_fetch,
166            persist_across_boot,
167        } = item;
168        let tag = Tag::new(tag)?;
169        let name = ServiceName::new(service_name)?;
170        if let Some(existing) = config.entry(name.clone()).or_default().insert(
171            tag,
172            TagConfig { selectors, max_bytes, min_seconds_between_fetch, persist_across_boot },
173        ) {
174            bail!("Duplicate TagConfig found: {:?}", existing);
175        }
176    }
177    Ok(())
178}
179
180pub fn load_configuration_files() -> Result<Config, Error> {
181    load_configuration_files_from(CONFIG_GLOB)
182}
183
184pub fn load_configuration_files_from(path: &str) -> Result<Config, Error> {
185    let mut config = HashMap::new();
186    for file_path in glob(path)? {
187        try_insert_items(&mut config, &std::fs::read_to_string(file_path?)?)?;
188    }
189    Ok(config)
190}
191
192#[cfg(test)]
193mod test {
194    use super::*;
195
196    impl From<TaggedPersist> for TagConfig {
197        fn from(
198            TaggedPersist {
199                tag: _,
200                service_name: _,
201                selectors,
202                max_bytes,
203                min_seconds_between_fetch,
204                persist_across_boot,
205            }: TaggedPersist,
206        ) -> Self {
207            Self { selectors, max_bytes, min_seconds_between_fetch, persist_across_boot }
208        }
209    }
210
211    #[fuchsia::test]
212    fn verify_insert_logic() {
213        let mut config = HashMap::new();
214        let taga_servab = "[{tag: 'tag-a', service_name: 'serv-a', max_bytes: 10, \
215                           min_seconds_between_fetch: 31, selectors: ['foo', 'bar']}, \
216                           {tag: 'tag-a', service_name: 'serv-b', max_bytes: 20, \
217                           min_seconds_between_fetch: 32, selectors: ['baz'], \
218                           persist_across_boot: true }]";
219        let tagb_servb = "[{tag: 'tag-b', service_name: 'serv-b', max_bytes: 30, \
220                          min_seconds_between_fetch: 33, selectors: ['quux']}]";
221        // Numbers not allowed in names
222        let bad_tag = "[{tag: 'tag-b1', service_name: 'serv-b', max_bytes: 30, \
223                       min_seconds_between_fetch: 33, selectors: ['quux']}]";
224        // Underscores not allowed in names
225        let bad_serv = "[{tag: 'tag-b', service_name: 'serv_b', max_bytes: 30, \
226                        min_seconds_between_fetch: 33, selectors: ['quux']}]";
227        let persist_aa = TaggedPersist {
228            tag: "tag-a".to_string(),
229            service_name: "serv-a".to_string(),
230            max_bytes: 10,
231            min_seconds_between_fetch: 31,
232            selectors: vec!["foo".to_string(), "bar".to_string()],
233            persist_across_boot: false,
234        };
235        let persist_ba = TaggedPersist {
236            tag: "tag-a".to_string(),
237            service_name: "serv-b".to_string(),
238            max_bytes: 20,
239            min_seconds_between_fetch: 32,
240            selectors: vec!["baz".to_string()],
241            persist_across_boot: true,
242        };
243        let persist_bb = TaggedPersist {
244            tag: "tag-b".to_string(),
245            service_name: "serv-b".to_string(),
246            max_bytes: 30,
247            min_seconds_between_fetch: 33,
248            selectors: vec!["quux".to_string()],
249            persist_across_boot: false,
250        };
251
252        try_insert_items(&mut config, taga_servab).unwrap();
253        try_insert_items(&mut config, tagb_servb).unwrap();
254        assert_eq!(config.len(), 2);
255        let service_a = config.get("serv-a").unwrap();
256        assert_eq!(service_a.len(), 1);
257        assert_eq!(service_a.get("tag-a"), Some(&persist_aa.clone().into()));
258        let service_b = config.get("serv-b").unwrap();
259        assert_eq!(service_b.len(), 2);
260        assert_eq!(service_b.get("tag-a"), Some(&persist_ba.clone().into()));
261        assert_eq!(service_b.get("tag-b"), Some(&persist_bb.clone().into()));
262
263        assert!(try_insert_items(&mut config, bad_tag).is_err());
264        assert!(try_insert_items(&mut config, bad_serv).is_err());
265        // Can't duplicate tags in the same service
266        assert!(try_insert_items(&mut config, tagb_servb).is_err());
267    }
268
269    #[test]
270    fn test_tag_equals_str() {
271        assert_eq!(&Tag::new("foo").unwrap(), "foo");
272    }
273}