1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
// Copyright 2020 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use anyhow::{bail, Error};
use glob::glob;
use regex::Regex;
use serde_derive::Deserialize;
use std::borrow::Borrow;
use std::collections::HashMap;
use std::fmt::Display;
use std::ops::Deref;
use std::sync::LazyLock;

/// The outer map is service_name; the inner is tag.
pub type Config = HashMap<ServiceName, HashMap<Tag, TagConfig>>;

/// Schema for config-file entries. Each config file is a JSON array of these.
#[derive(Deserialize, Default, Debug, PartialEq)]
#[cfg_attr(test, derive(Clone))]
#[serde(deny_unknown_fields)]
struct TaggedPersist {
    /// The Inspect data defined here will be published under this tag.
    /// Tags must not be duplicated within a service, even between files.
    /// Tags must conform to /[a-z][a-z-]*/.
    pub tag: String,
    /// Each tag will only be requestable via a named service. Multiple tags can use the
    /// same service name, which will be published and routed as DataPersistence_{service_name}.
    /// Service names must conform to /[a-z][a-z-]*/.
    pub service_name: String,
    /// These selectors will be fetched and stored for publication on the next boot.
    pub selectors: Vec<String>,
    /// This is the max size of the file saved, which is the JSON-serialized version
    /// of the selectors' data.
    pub max_bytes: usize,
    /// Persistence requests will be throttled to this. Requests received early will be delayed.
    pub min_seconds_between_fetch: i64,
}

/// Configuration for a single tag for a single service.
///
/// See [`TaggedPersist`] for the meaning of corresponding fields.
#[derive(Debug, Eq, PartialEq)]
pub struct TagConfig {
    pub selectors: Vec<String>,
    pub max_bytes: usize,
    pub min_seconds_between_fetch: i64,
}

/// Wrapper class for a valid tag name.
///
/// This is a witness class that can only be constructed from a `String` that
/// matches [`NAME_PATTERN`].
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct Tag(String);

/// Wrapper class for a valid service name.
///
/// This is a witness class that can only be constructed from a `String` that
/// matches [`NAME_PATTERN`].
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ServiceName(String);

/// A regular expression corresponding to a valid tag or service name.
const NAME_PATTERN: &str = r"^[a-z][a-z-]*$";

static NAME_VALIDATOR: LazyLock<Regex> = LazyLock::new(|| Regex::new(NAME_PATTERN).unwrap());

impl Tag {
    pub fn new(tag: impl Into<String>) -> Result<Self, Error> {
        let tag = tag.into();
        if !NAME_VALIDATOR.is_match(&tag) {
            bail!("Invalid tag {} must match [a-z][a-z-]*", tag);
        }
        Ok(Self(tag))
    }

    pub fn as_str(&self) -> &str {
        self.0.as_ref()
    }
}

impl ServiceName {
    pub fn new(name: String) -> Result<Self, Error> {
        if !NAME_VALIDATOR.is_match(&name) {
            bail!("Invalid service name {} must match [a-z][a-z-]*", name);
        }
        Ok(Self(name))
    }
}

/// Allow `Tag` to be treated like a `&str` for display, etc.
impl Deref for Tag {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        self.as_str()
    }
}

/// Allow `ServiceName` to be treated like a `&str` for display, etc.
impl Deref for ServiceName {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        let Self(tag) = self;
        tag
    }
}

/// Allow treating `Tag` as a `&str` for, e.g., HashMap indexing operations.
impl Borrow<str> for Tag {
    fn borrow(&self) -> &str {
        self
    }
}

/// Allow treating `ServiceName` as a `&str` for, e.g., HashMap indexing
/// operations.
impl Borrow<str> for ServiceName {
    fn borrow(&self) -> &str {
        self
    }
}

impl Display for Tag {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let Self(name) = self;
        name.fmt(f)
    }
}

impl PartialEq<str> for Tag {
    fn eq(&self, other: &str) -> bool {
        self.0 == other
    }
}

impl Display for ServiceName {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let Self(name) = self;
        name.fmt(f)
    }
}

const CONFIG_GLOB: &str = "/config/data/*.persist";

fn try_insert_items(config: &mut Config, config_text: &str) -> Result<(), Error> {
    let items = serde_json5::from_str::<Vec<TaggedPersist>>(config_text)?;
    for item in items {
        let TaggedPersist { tag, service_name, selectors, max_bytes, min_seconds_between_fetch } =
            item;
        let tag = Tag::new(tag)?;
        let name = ServiceName::new(service_name)?;
        if let Some(existing) = config
            .entry(name.clone())
            .or_default()
            .insert(tag, TagConfig { selectors, max_bytes, min_seconds_between_fetch })
        {
            bail!("Duplicate TagConfig found: {:?}", existing);
        }
    }
    Ok(())
}

pub fn load_configuration_files() -> Result<Config, Error> {
    load_configuration_files_from(CONFIG_GLOB)
}

pub fn load_configuration_files_from(path: &str) -> Result<Config, Error> {
    let mut config = HashMap::new();
    for file_path in glob(path)? {
        try_insert_items(&mut config, &std::fs::read_to_string(file_path?)?)?;
    }
    Ok(config)
}

#[cfg(test)]
mod test {
    use super::*;

    impl From<TaggedPersist> for TagConfig {
        fn from(
            TaggedPersist {
                tag: _,
                service_name: _,
                selectors,
                max_bytes,
                min_seconds_between_fetch,
            }: TaggedPersist,
        ) -> Self {
            Self { selectors, max_bytes, min_seconds_between_fetch }
        }
    }

    #[fuchsia::test]
    fn verify_insert_logic() {
        let mut config = HashMap::new();
        let taga_servab = "[{tag: 'tag-a', service_name: 'serv-a', max_bytes: 10, \
                           min_seconds_between_fetch: 31, selectors: ['foo', 'bar']}, \
                           {tag: 'tag-a', service_name: 'serv-b', max_bytes: 20, \
                           min_seconds_between_fetch: 32, selectors: ['baz']}, ]";
        let tagb_servb = "[{tag: 'tag-b', service_name: 'serv-b', max_bytes: 30, \
                          min_seconds_between_fetch: 33, selectors: ['quux']}]";
        // Numbers not allowed in names
        let bad_tag = "[{tag: 'tag-b1', service_name: 'serv-b', max_bytes: 30, \
                       min_seconds_between_fetch: 33, selectors: ['quux']}]";
        // Underscores not allowed in names
        let bad_serv = "[{tag: 'tag-b', service_name: 'serv_b', max_bytes: 30, \
                        min_seconds_between_fetch: 33, selectors: ['quux']}]";
        let persist_aa = TaggedPersist {
            tag: "tag-a".to_string(),
            service_name: "serv-a".to_string(),
            max_bytes: 10,
            min_seconds_between_fetch: 31,
            selectors: vec!["foo".to_string(), "bar".to_string()],
        };
        let persist_ba = TaggedPersist {
            tag: "tag-a".to_string(),
            service_name: "serv-b".to_string(),
            max_bytes: 20,
            min_seconds_between_fetch: 32,
            selectors: vec!["baz".to_string()],
        };
        let persist_bb = TaggedPersist {
            tag: "tag-b".to_string(),
            service_name: "serv-b".to_string(),
            max_bytes: 30,
            min_seconds_between_fetch: 33,
            selectors: vec!["quux".to_string()],
        };

        assert!(try_insert_items(&mut config, taga_servab).is_ok());
        assert!(try_insert_items(&mut config, tagb_servb).is_ok());
        assert_eq!(config.len(), 2);
        let service_a = config.get("serv-a").unwrap();
        assert_eq!(service_a.len(), 1);
        assert_eq!(service_a.get("tag-a"), Some(&persist_aa.clone().into()));
        let service_b = config.get("serv-b").unwrap();
        assert_eq!(service_b.len(), 2);
        assert_eq!(service_b.get("tag-a"), Some(&persist_ba.clone().into()));
        assert_eq!(service_b.get("tag-b"), Some(&persist_bb.clone().into()));

        assert!(try_insert_items(&mut config, bad_tag).is_err());
        assert!(try_insert_items(&mut config, bad_serv).is_err());
        // Can't duplicate tags in the same service
        assert!(try_insert_items(&mut config, tagb_servb).is_err());
    }

    #[test]
    fn test_tag_equals_str() {
        assert_eq!(&Tag::new("foo").unwrap(), "foo");
    }
}