Skip to main content

dynamic_config_store_core/
documents.rs

1//! Folding several keys into the one document `fetch` returns.
2//!
3//! A store that reads a prefix — or a list of keys the caller named — still
4//! has to hand the loader a single [`Fetched`], because [`Fetched`] carries one
5//! text and one format on purpose and widening it would change a trait every
6//! external store implements. So the fold happens in the store, before `fetch`
7//! returns, and this is the part of it that is the same in every store: the
8//! ordering rule, the collision report, and the two limits an untrusted key
9//! list has to be held to.
10//!
11//! What is *not* here is how a store finds its keys. etcd has a range read,
12//! Consul has `?recurse`, Redis has `SCAN` — one call each, and nothing about
13//! them generalises.
14//!
15//! # The two rules, and why they differ
16//!
17//! A caller who **names keys** is expressing an order, exactly the way a
18//! caller who names files is: `.file("base.toml").file("local.toml")` merges
19//! in call order and the later file wins. So does [`Overlap::LaterWins`].
20//!
21//! A caller who names a **prefix** is expressing something else — "these are
22//! the sections of my configuration" — and the order the server lists them in
23//! is not a precedence anybody chose. Two keys under one prefix supplying the
24//! same path is a deployment bug, so [`Overlap::Refused`] reports it instead of
25//! resolving it. The report names the two keys and the paths, never the values.
26
27use dynamic_config::{Error, Fetched, Format, Value};
28
29/// The most keys one prefix read will fold into a document.
30///
31/// A server can answer a range read with anything at all, and the answer is
32/// held in memory twice over — once as text, once parsed — before it becomes a
33/// document. Five hundred sections is already far past what a configuration
34/// has and far short of what a mistyped prefix (`""`, or a prefix that is a
35/// whole tenant's key space) would return, which is the pair of numbers this
36/// has to sit between.
37///
38/// It bounds a *prefix* read only. An explicit list of keys was written down
39/// by the caller, so its length is theirs to choose.
40pub const MOST_KEYS: usize = 512;
41
42/// How many colliding paths a refusal names before it stops counting.
43///
44/// Two documents that overlap completely would otherwise put every leaf they
45/// have into one error message.
46const MOST_REPORTED_PATHS: usize = 8;
47
48/// What happens when two of a source's keys supply the same path.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum Overlap {
51    /// Later wins, in the order the documents are given.
52    ///
53    /// For a list of keys the caller wrote down: the list *is* the precedence,
54    /// the same way a list of `.file(..)` calls is. Tables merge deeply and
55    /// arrays are replaced whole — the rule
56    /// [`Value::merge`](dynamic_config::Value::merge) already implements,
57    /// because it is the rule every layer in this family already means.
58    LaterWins,
59    /// An overlap is an error naming both keys and the paths.
60    ///
61    /// For a prefix read, where the keys arrive in whatever order the server
62    /// felt like and no order between them would be defensible.
63    Refused,
64}
65
66/// Folds `documents` into the one document a `fetch` returns.
67///
68/// `documents` is `(key, text)` in the order the merge should apply. `key` is
69/// only ever used to name a document in a diagnostic; `described` is the
70/// store's own [`describe()`](dynamic_config::RemoteSource::describe).
71///
72/// **A single document is passed through byte for byte.** It is not parsed and
73/// not re-rendered, so a one-key read costs nothing, cannot fail on a format
74/// whose feature is off, and produces exactly the bytes that were stored. Two
75/// or more are parsed, merged, and rendered back into `format` — which does
76/// need that format's feature, and says so if it is missing.
77///
78/// **A partial read is not a partial document.** Whatever calls this has
79/// already decided that every key was readable: one unreadable key out of five
80/// must fail the whole fetch rather than merge the four, because a
81/// configuration silently missing a section is worse than a refresh that
82/// failed and left the last known good document serving.
83///
84/// # Errors
85///
86/// If `documents` is empty, if any document does not parse, if the merged tree
87/// cannot be rendered back into `format`, or — under [`Overlap::Refused`] — if
88/// two documents supply the same path.
89pub fn merged(
90    documents: &[(String, String)],
91    format: Format,
92    overlap: Overlap,
93    described: &str,
94) -> Result<Fetched, Error> {
95    match documents {
96        [] => Err(Error::remote(format!(
97            "{described}: no key held a value, so there is nothing to load"
98        ))),
99
100        // Byte for byte: parsing and re-rendering one document would rewrite
101        // key order and drop comments for no gain at all, and would make a
102        // single-key read need a format feature it never needed before.
103        [(_, only)] => Ok(Fetched::new(only.clone(), format)),
104
105        _ => Ok(Fetched::new(
106            folded(documents, format, overlap, described)?
107                .render(format)
108                .map_err(|error| {
109                    Error::remote(format!(
110                    "{described}: the merged document cannot be written back as {format:?}: {error}"
111                ))
112                })?,
113            format,
114        )),
115    }
116}
117
118/// The merge itself, kept apart so [`merged`] reads as the three cases it is.
119fn folded(
120    documents: &[(String, String)],
121    format: Format,
122    overlap: Overlap,
123    described: &str,
124) -> Result<Value, Error> {
125    // Kept rather than folded away, so a refusal can say which *earlier* key
126    // supplied the path the current one collided with. Only the error path
127    // walks them, so the cost is a vector of trees that were parsed anyway.
128    let mut parsed: Vec<(&str, Value)> = Vec::with_capacity(documents.len());
129
130    for (key, text) in documents {
131        let value = Value::parse(text, format).map_err(|error| {
132            Error::remote(format!(
133                "{described}: `{key}` is not a {format:?} document: {error}"
134            ))
135        })?;
136
137        parsed.push((key.as_str(), value));
138    }
139
140    let mut folded = parsed[0].1.clone();
141
142    for (key, value) in &parsed[1..] {
143        if overlap == Overlap::Refused {
144            let clashes = folded.overlapping_paths(value);
145
146            if !clashes.is_empty() {
147                return Err(collision(&parsed, key, &clashes, described));
148            }
149        }
150
151        folded.merge(value.clone());
152    }
153
154    Ok(folded)
155}
156
157/// The report two overlapping keys earn: both key names, and the paths.
158///
159/// Paths and never values — [`Value::overlapping_paths`] is built that way, and
160/// a collision report is a diagnostic, which in this family means it names what
161/// moved and never what was there.
162fn collision(parsed: &[(&str, Value)], key: &str, clashes: &[String], described: &str) -> Error {
163    // Which earlier key actually supplied the first colliding path. The merged
164    // tree cannot say — it is every earlier key at once — so the answer comes
165    // from the documents themselves, and only here, on the path that is already
166    // failing.
167    let before = parsed
168        .iter()
169        .position(|(name, _)| *name == key)
170        .unwrap_or(parsed.len());
171
172    let earlier = clashes
173        .first()
174        .and_then(|path| {
175            // Backwards: with later-wins off nobody won, but the *nearest*
176            // earlier key is the one a reader will look at first.
177            parsed[..before]
178                .iter()
179                .rev()
180                .find(|(_, value)| value.get(path).is_some())
181        })
182        .map_or("an earlier key", |(name, _)| *name);
183
184    let named: Vec<&str> = clashes
185        .iter()
186        .take(MOST_REPORTED_PATHS)
187        .map(String::as_str)
188        .collect();
189
190    let more = clashes.len().saturating_sub(named.len());
191    let and_more = if more == 0 {
192        String::new()
193    } else {
194        format!(" (and {more} more)")
195    };
196
197    Error::remote(format!(
198        "{described}: `{earlier}` and `{key}` both supply {}{and_more}; keys read \
199         as a prefix are sections that must not overlap, and the order a server \
200         lists them in is not a precedence — name the keys instead if one is \
201         meant to win",
202        named.join(", ")
203    ))
204}
205
206/// The format every key's extension agrees on.
207///
208/// One source reads one format: [`Fetched`] carries one, the merge happens in
209/// one, and a caller who wants a JSON key and a TOML key has two sources rather
210/// than one — which already works and is what the tedium this feature removes
211/// was never about.
212///
213/// So a list whose extensions disagree is a mistake worth catching by name.
214/// Parsing `myapp/server.toml` as JSON because `myapp/db.json` came first
215/// produces a syntax error about a document that has no syntax error in it,
216/// which is a bad half-hour for whoever gets it.
217///
218/// `Ok(None)` when no key names a format at all — the store's `with_format` is
219/// the answer, exactly as it is for one key with no extension.
220///
221/// # Errors
222///
223/// The two keys that disagree, worded for a store to quote into its own error.
224/// Never a value: a key name is caller input, and it is all this sees.
225pub fn agreed_format(keys: &[String]) -> Result<Option<Format>, String> {
226    let mut agreed: Option<(&str, Format)> = None;
227
228    for key in keys {
229        let Some(format) = Format::from_key(key) else {
230            continue;
231        };
232
233        match agreed {
234            Some((named, first)) if first != format => {
235                return Err(format!(
236                    "`{named}` names {first:?} and `{key}` names {format:?}; one source \
237                     reads one format — call `with_format` to settle it, or install one \
238                     source per format"
239                ));
240            }
241            Some(_) => {}
242            None => agreed = Some((key, format)),
243        }
244    }
245
246    Ok(agreed.map(|(_, format)| format))
247}
248
249/// Refuses a key list longer than a configuration has any business being.
250///
251/// A prefix is caller input and the answer to it is server input: an empty
252/// prefix, or one pointed at a whole tenant's key space, matches everything
253/// there is. Called *before* the values are read where the protocol allows the
254/// count to be known first, and immediately after the one call that carries
255/// them where it does not.
256///
257/// # Errors
258///
259/// If `matched` is above [`MOST_KEYS`].
260pub fn within_key_budget(matched: usize, described: &str) -> Result<(), Error> {
261    if matched <= MOST_KEYS {
262        return Ok(());
263    }
264
265    Err(Error::remote(format!(
266        "{described}: the prefix matches {matched} keys, above the {MOST_KEYS} \
267         one document is folded from; narrow the prefix"
268    )))
269}
270
271/// Refuses a key the server returned that is not under the prefix that was
272/// asked for.
273///
274/// Not paranoia about a lying server so much as about the *query*: Redis'
275/// `SCAN MATCH` takes a glob, so a prefix containing `*`, `?` or `[` would
276/// match keys the caller never named, and Consul's `?recurse` is a string
277/// prefix that a proxy could rewrite. The literal check is one comparison and
278/// it makes the prefix mean what it says in every store.
279///
280/// # Errors
281///
282/// If `key` does not start with `prefix`.
283pub fn under_prefix(key: &str, prefix: &str, described: &str) -> Result<(), Error> {
284    if key.starts_with(prefix) {
285        return Ok(());
286    }
287
288    Err(Error::remote(format!(
289        "{described}: the store answered with `{key}`, which is not under the \
290         prefix that was asked for"
291    )))
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn documents(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
299        pairs
300            .iter()
301            .map(|(key, text)| ((*key).to_owned(), (*text).to_owned()))
302            .collect()
303    }
304
305    /// The rule a list of keys inherits from a list of files: call order, and
306    /// the later one wins.
307    #[test]
308    fn a_named_list_merges_in_order_and_the_later_key_wins() {
309        let documents = documents(&[
310            ("myapp/base", r#"{"db": {"host": "a", "port": 1}}"#),
311            ("myapp/local", r#"{"db": {"port": 2}}"#),
312        ]);
313
314        let fetched = merged(&documents, Format::Json, Overlap::LaterWins, "store")
315            .expect("two documents merge");
316
317        let tree = Value::parse(&fetched.text, Format::Json).expect("the result is a document");
318
319        assert_eq!(tree.get("db.host"), Some(&Value::String("a".to_owned())));
320        assert_eq!(tree.get("db.port"), Some(&Value::Integer(2)));
321    }
322
323    /// Disjoint sections are the case a prefix read is *for*, and it has to
324    /// work identically under either rule.
325    #[test]
326    fn disjoint_sections_fold_into_one_document_under_either_rule() {
327        let documents = documents(&[
328            ("myapp/db", r#"{"db": {"host": "a"}}"#),
329            ("myapp/server", r#"{"server": {"port": 8080}}"#),
330        ]);
331
332        for overlap in [Overlap::LaterWins, Overlap::Refused] {
333            let fetched =
334                merged(&documents, Format::Json, overlap, "store").expect("nothing overlaps");
335            let tree = Value::parse(&fetched.text, Format::Json).expect("the result is a document");
336
337            assert_eq!(tree.get("db.host"), Some(&Value::String("a".to_owned())));
338            assert_eq!(tree.get("server.port"), Some(&Value::Integer(8080)));
339        }
340    }
341
342    /// The refusal names both keys and the path, so the person reading it can
343    /// go and fix the deployment.
344    #[test]
345    fn a_prefix_collision_names_both_keys_and_the_path() {
346        let documents = documents(&[
347            ("myapp/db", r#"{"db": {"host": "a"}}"#),
348            ("myapp/server", r#"{"server": {"port": 1}}"#),
349            ("myapp/extra", r#"{"db": {"host": "b"}}"#),
350        ]);
351
352        let error = merged(&documents, Format::Json, Overlap::Refused, "store")
353            .expect_err("two keys supply db.host");
354
355        let printed = error.to_string();
356
357        assert!(printed.contains("myapp/db"), "{printed}");
358        assert!(printed.contains("myapp/extra"), "{printed}");
359        assert!(printed.contains("db.host"), "{printed}");
360        // The key that did not collide is not dragged into the report.
361        assert!(!printed.contains("myapp/server"), "{printed}");
362    }
363
364    /// The rule the whole repository is built around, at the one new error
365    /// path that has both documents in its hands.
366    #[test]
367    fn a_collision_report_names_paths_and_never_values() {
368        let documents = documents(&[
369            ("myapp/db", r#"{"db": {"password": "hunter2-left"}}"#),
370            ("myapp/extra", r#"{"db": {"password": "hunter2-right"}}"#),
371        ]);
372
373        let error = merged(&documents, Format::Json, Overlap::Refused, "store")
374            .expect_err("both keys supply db.password");
375
376        let printed = format!("{error} {error:?}");
377
378        assert!(printed.contains("db.password"), "{printed}");
379        assert!(!printed.contains("hunter2"), "{printed}");
380    }
381
382    /// A document that does not parse names the key it came from — with a
383    /// prefix read the whole point is that the caller does not know which key
384    /// is which until something says so.
385    #[test]
386    fn a_document_that_does_not_parse_names_its_key_and_not_its_contents() {
387        let documents = documents(&[
388            ("myapp/db", r#"{"db": {"host": "a"}}"#),
389            ("myapp/broken", r#"{"password": "hunter2"#),
390        ]);
391
392        let error = merged(&documents, Format::Json, Overlap::LaterWins, "store")
393            .expect_err("the second document is truncated");
394
395        let printed = format!("{error} {error:?}");
396
397        assert!(printed.contains("myapp/broken"), "{printed}");
398        assert!(!printed.contains("hunter2"), "{printed}");
399    }
400
401    /// One key is the old behaviour, and has to stay byte-identical: a
402    /// round trip through the tree would reorder keys and drop comments.
403    #[test]
404    fn one_document_is_handed_over_exactly_as_it_was_stored() {
405        let stored = "{\n  \"zebra\": 1,\n  \"apple\": 2\n}\n";
406
407        let fetched = merged(
408            &documents(&[("myapp/db", stored)]),
409            Format::Json,
410            Overlap::Refused,
411            "store",
412        )
413        .expect("one document needs no merge");
414
415        assert_eq!(fetched.text, stored);
416    }
417
418    #[test]
419    fn no_keys_at_all_is_a_failure_rather_than_an_empty_document() {
420        let error = merged(&[], Format::Json, Overlap::Refused, "store")
421            .expect_err("an empty set is not a configuration");
422
423        assert!(error.to_string().contains("nothing to load"), "{error}");
424    }
425
426    /// The budget is what stands between a mistyped prefix and a process that
427    /// pulls a whole key space into memory.
428    #[test]
429    fn a_prefix_matching_more_keys_than_the_budget_is_refused() {
430        within_key_budget(MOST_KEYS, "store").expect("the budget itself is allowed");
431
432        let error = within_key_budget(MOST_KEYS + 1, "store").expect_err("one too many");
433
434        assert!(error.to_string().contains("narrow the prefix"), "{error}");
435    }
436
437    #[test]
438    fn keys_that_name_one_format_agree_and_keys_that_name_none_defer() {
439        let keys = ["a/db.json".to_owned(), "a/server.json".to_owned()];
440        assert_eq!(agreed_format(&keys), Ok(Some(Format::Json)));
441
442        // An extension nobody recognises is not a disagreement — it is a key
443        // with no opinion, which `with_format` already covers.
444        let keys = ["a/db.json".to_owned(), "a/server".to_owned()];
445        assert_eq!(agreed_format(&keys), Ok(Some(Format::Json)));
446
447        assert_eq!(agreed_format(&["a/db".to_owned()]), Ok(None));
448        assert_eq!(agreed_format(&[]), Ok(None));
449    }
450
451    /// The confusing failure this prevents: `server.toml` parsed as JSON is a
452    /// syntax error about a file with no syntax error in it.
453    #[test]
454    fn keys_naming_two_formats_name_both_keys_rather_than_guessing() {
455        let keys = ["a/db.json".to_owned(), "a/server.toml".to_owned()];
456
457        let complaint = agreed_format(&keys).expect_err("json and toml cannot both be it");
458
459        assert!(complaint.contains("a/db.json"), "{complaint}");
460        assert!(complaint.contains("a/server.toml"), "{complaint}");
461        assert!(complaint.contains("with_format"), "{complaint}");
462    }
463
464    /// A glob metacharacter in a prefix is the concrete way this happens: a
465    /// Redis `SCAN MATCH my[a]pp/*` matches `myapp/...`, which the caller
466    /// never asked for.
467    #[test]
468    fn a_key_outside_the_prefix_is_refused() {
469        under_prefix("myapp/db", "myapp/", "store").expect("this one is under it");
470
471        let error =
472            under_prefix("other/db", "myapp/", "store").expect_err("that one is not under it");
473
474        assert!(error.to_string().contains("other/db"), "{error}");
475    }
476
477    /// Arrays are replaced whole rather than concatenated — the same thing a
478    /// later file means by supplying a list, and the thing most likely to be
479    /// assumed otherwise.
480    #[test]
481    fn a_later_key_replaces_a_list_rather_than_appending_to_it() {
482        let documents = documents(&[
483            ("myapp/base", r#"{"db": {"hosts": ["a", "b"]}}"#),
484            ("myapp/local", r#"{"db": {"hosts": ["c"]}}"#),
485        ]);
486
487        let fetched = merged(&documents, Format::Json, Overlap::LaterWins, "store")
488            .expect("two documents merge");
489        let tree = Value::parse(&fetched.text, Format::Json).expect("the result is a document");
490
491        assert_eq!(
492            tree.get("db.hosts"),
493            Some(&Value::Array(vec![Value::String("c".to_owned())]))
494        );
495    }
496}