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}