dynamic_config/write.rs
1//! Writing a configuration back out.
2//!
3//! For the programs that own their configuration file rather than read one
4//! somebody else deploys: a CLI persisting preferences, a setup wizard, an
5//! admin endpoint. A server should not be writing its own `/etc` file.
6//!
7//! Two things are load-bearing here.
8//!
9//! **The write is atomic.** A temporary file next to the target, then a rename.
10//! This crate's own watcher is very likely watching that directory, and a
11//! partial file would be read, fail to parse, and log a failure that never
12//! happened — the exact "editor saves half a file" case the watcher was built
13//! to survive, caused by us.
14//!
15//! **The output is the section, nested under its key**, so what comes out can
16//! be read straight back in. A file whose shape differs from the one the loader
17//! expects is not a saved configuration, it is a new bug.
18//!
19//! Secrets go to disk in the clear. `#[config(secret)]` keeps a value out of
20//! logs; it cannot keep it out of a file the program was asked to write. On
21//! Unix the file is created `0600` — *created*, not chmodded afterwards, so
22//! there is no window in which it is readable by anyone else. That is the most
23//! that can be done without refusing the request.
24
25use std::fs::{self, OpenOptions};
26use std::io::Write;
27use std::path::Path;
28use std::sync::atomic::{AtomicU64, Ordering};
29
30use figment::value::{Dict, Value};
31use serde::Serialize;
32
33use crate::error::{Error, ErrorKind, Origin};
34use crate::source::Format;
35
36/// Writes `value` to `path` as the `key` section of a `format` document.
37///
38/// The format is taken from the argument rather than the extension, so a file
39/// named `config` or `.myapprc` can still be written.
40///
41/// # Errors
42///
43/// If `value` cannot be serialized, the format's feature is not enabled, or the
44/// file cannot be written.
45pub fn save<T: Serialize>(
46 value: &T,
47 path: impl AsRef<Path>,
48 format: Format,
49 key: &str,
50) -> Result<(), Error> {
51 let path = path.as_ref();
52
53 write_atomically(path, &render(&document_of(value, key)?, format)?)
54}
55
56/// Writes an already-built section, without going through `Serialize`.
57///
58/// The cache holds a resolved tree rather than a struct, so it arrives here
59/// shaped already.
60pub(crate) fn save_dict(
61 section: &Dict,
62 path: &Path,
63 format: Format,
64 key: &str,
65) -> Result<(), Error> {
66 let mut document = Dict::new();
67 document.insert(key.to_owned(), Value::from(section.clone()));
68
69 let rendered = render(&document, format)?;
70
71 write_atomically(path, &rendered)
72}
73
74/// [`save_dict`], through an [`Encryptor`](crate::Encryptor) — what the
75/// encrypted last-known-good cache writes with.
76#[cfg(feature = "decrypt")]
77pub(crate) fn save_dict_encrypted(
78 section: &Dict,
79 path: &Path,
80 format: Format,
81 key: &str,
82 encryptor: &dyn crate::Encryptor,
83) -> Result<(), Error> {
84 let mut document = Dict::new();
85 document.insert(key.to_owned(), Value::from(section.clone()));
86
87 let mut rendered = render(&document, format)?;
88 let encrypted = encryptor
89 .encrypt(rendered.as_bytes())
90 .map_err(|error| error.prepend_key(encryptor.describe()));
91
92 // The same courtesy `save_encrypted` extends: the plaintext does not
93 // linger in freed memory on either path.
94 {
95 use zeroize::Zeroize;
96
97 rendered.zeroize();
98 }
99
100 write_bytes_atomically(path, &encrypted?)
101}
102
103/// One tree, as the text of a `format` document.
104///
105/// `pub(crate)` rather than private because it is one half of the parse seam —
106/// [`Value::render`](crate::Value::render) is this, reached from outside — and a
107/// second serializer written next to it would be a second set of edge cases for
108/// nulls and integer widths.
109pub(crate) fn render(document: &Dict, format: Format) -> Result<String, Error> {
110 // With no format feature on, every arm below is compiled out.
111 #[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
112 let _ = document;
113
114 match format {
115 #[cfg(feature = "json")]
116 Format::Json => serde_json::to_string_pretty(document)
117 .map(|mut rendered| {
118 rendered.push('\n');
119 rendered
120 })
121 .map_err(serialization),
122
123 #[cfg(feature = "toml")]
124 Format::Toml => toml::to_string_pretty(document).map_err(serialization),
125
126 #[cfg(feature = "yaml")]
127 Format::Yaml => serde_yaml::to_string(document).map_err(serialization),
128
129 #[allow(unreachable_patterns)]
130 format => Err(Error::new(
131 ErrorKind::Backend,
132 format!(
133 "cannot write {format:?} because the `{}` feature is not enabled",
134 format.feature()
135 ),
136 )),
137 }
138}
139
140#[allow(dead_code)]
141fn serialization(error: impl std::fmt::Display) -> Error {
142 Error::new(ErrorKind::Type, error.to_string())
143}
144
145/// As [`save`], but refuses if `path` already exists.
146///
147/// Viper calls this `SafeWriteConfig`. The case is a setup wizard or a `--init`
148/// subcommand: writing a configuration somebody already wrote by hand, silently,
149/// is the one failure mode those have.
150///
151/// Written directly rather than through a temporary file and a rename. The
152/// rename is what makes [`save`] atomic *against an existing file*, and there is
153/// no existing file here by definition — so `create_new` on the target itself is
154/// both simpler and free of the race a check-then-write would have.
155///
156/// # Errors
157///
158/// If the file exists, if `value` cannot be serialized, if the format's feature
159/// is off, or if the file cannot be written.
160pub fn save_new<T: Serialize>(
161 value: &T,
162 path: impl AsRef<Path>,
163 format: Format,
164 key: &str,
165) -> Result<(), Error> {
166 let path = path.as_ref();
167 let rendered = render(&document_of(value, key)?, format)?;
168
169 // `create_and_fill` cleans up after its own failures — and only its own:
170 // a file the open refused to create is somebody else's, and removing it
171 // here would destroy exactly the thing this function exists to protect.
172 create_and_fill(path, &rendered).map_err(|error| {
173 Error::new(ErrorKind::Io, error.to_string()).with_origin(Origin::File(path.to_owned()))
174 })
175}
176
177/// As [`save`], encrypting the document before it reaches the disk.
178///
179/// The counterpart to reading a `secrets.json.age`. The encryptor is passed
180/// here rather than installed process-wide, because *who may read this file* is
181/// a decision about this write.
182///
183/// ```no_run
184/// # #[cfg(feature = "age")] {
185/// # use serde::Serialize;
186/// # #[derive(Serialize)] struct Db { host: String }
187/// # let config = Db { host: "localhost".to_owned() };
188/// use dynamic_config::age::Recipients;
189///
190/// let recipients = Recipients::from_public_keys(["age1ql3z7..."])?;
191///
192/// dynamic_config::save_encrypted(
193/// &config,
194/// "secrets.json.age",
195/// dynamic_config::Format::Json,
196/// "db",
197/// &recipients,
198/// )?;
199/// # }
200/// # Ok::<(), dynamic_config::Error>(())
201/// ```
202///
203/// # Errors
204///
205/// If `value` cannot be serialized, the format's feature is off, encryption
206/// fails, or the file cannot be written.
207#[cfg(feature = "decrypt")]
208#[cfg_attr(docsrs, doc(cfg(feature = "decrypt")))]
209pub fn save_encrypted<T: Serialize>(
210 value: &T,
211 path: impl AsRef<Path>,
212 format: Format,
213 key: &str,
214 encryptor: &dyn crate::Encryptor,
215) -> Result<(), Error> {
216 let path = path.as_ref();
217 let mut rendered = render(&document_of(value, key)?, format)?;
218
219 let encrypted = encryptor
220 .encrypt(rendered.as_bytes())
221 .map_err(|error| error.prepend_key(encryptor.describe()));
222
223 // The rendered plaintext is the whole point of encrypting: it does not
224 // get to linger in freed memory on either the success or the failure
225 // path. The same courtesy the read side's `Plaintext` extends.
226 {
227 use zeroize::Zeroize;
228
229 rendered.zeroize();
230 }
231
232 write_bytes_atomically(path, &encrypted?)
233}
234
235/// The serialized value, nested under its section key.
236fn document_of<T: Serialize>(value: &T, key: &str) -> Result<Dict, Error> {
237 let section =
238 Value::serialize(value).map_err(|error| Error::new(ErrorKind::Type, error.to_string()))?;
239
240 let Value::Dict(_, section) = section else {
241 return Err(Error::new(
242 ErrorKind::Type,
243 format!(
244 "a configuration section must be a table, not {}",
245 shape(§ion)
246 ),
247 ));
248 };
249
250 let mut document = Dict::new();
251 document.insert(key.to_owned(), Value::from(section));
252
253 Ok(document)
254}
255
256/// Names what a value is without saying what it holds.
257///
258/// `{value:?}` here rendered the value itself: a configuration type that
259/// serializes to a bare string — a newtype over a token is the easy way to
260/// have one — put that string in the error, and this is a *write* path, so
261/// the string is as likely to be a credential as anything in the crate ever
262/// is. The shape is the whole of what the message needs.
263fn shape(value: &Value) -> &'static str {
264 match value {
265 Value::Dict(..) => "a table",
266 Value::Array(..) => "a list",
267 Value::Empty(..) => "nothing",
268 _ => "a single value",
269 }
270}
271
272/// Writes through a temporary file in the same directory, then renames.
273///
274/// Same directory on purpose: a rename is only atomic within one filesystem,
275/// and `/tmp` is routinely a different one.
276fn write_atomically(path: &Path, contents: &str) -> Result<(), Error> {
277 write_bytes_atomically(path, contents.as_bytes())
278}
279
280fn write_bytes_atomically(path: &Path, contents: &[u8]) -> Result<(), Error> {
281 let directory = path
282 .parent()
283 .filter(|parent| !parent.as_os_str().is_empty());
284 let temporary = match directory {
285 Some(directory) => directory.join(temporary_name(path)),
286 None => Path::new(".").join(temporary_name(path)),
287 };
288
289 let io = |error: std::io::Error| {
290 Error::new(ErrorKind::Io, error.to_string()).with_origin(Origin::File(path.to_owned()))
291 };
292
293 create_and_fill_bytes(&temporary, contents).map_err(io)?;
294
295 fs::rename(&temporary, path).map_err(|error| {
296 // The rename is what makes this atomic; if it fails there is a stray
297 // file to clean up rather than leave behind.
298 let _ = fs::remove_file(&temporary);
299
300 io(error)
301 })?;
302
303 // The rename itself lives in the directory, and a crash can lose an
304 // un-synced directory entry even though the file's bytes are safe.
305 // Best-effort: a filesystem that refuses (or a platform without the
306 // notion) still got the atomic rename, which is the part correctness
307 // rests on — durability of the *entry* is defence in depth.
308 #[cfg(unix)]
309 if let Some(directory) = directory {
310 if let Ok(handle) = fs::File::open(directory) {
311 let _ = handle.sync_all();
312 }
313 }
314
315 Ok(())
316}
317
318/// Creates the temporary file and writes `contents` into it.
319///
320/// Two properties matter more than they look:
321///
322/// **`create_new`** means the file must not already exist. A configuration file
323/// often holds secrets and often lives in a directory this process does not own
324/// exclusively; with plain `create`, a symlink planted at the temporary path
325/// would be followed and the secrets written wherever it pointed. Refusing to
326/// open an existing path removes that entirely, and the random component in the
327/// name keeps two writers from colliding on it by accident.
328///
329/// **`mode(0o600)`** applies at creation. Writing first and chmodding after
330/// leaves a window — short, but real — in which the secrets are readable by
331/// every user on the machine.
332fn create_and_fill(temporary: &Path, contents: &str) -> std::io::Result<()> {
333 create_and_fill_bytes(temporary, contents.as_bytes())
334}
335
336/// Creates `path` and writes `contents`, cleaning up after its own failures —
337/// and only its own.
338///
339/// The distinction is load-bearing: if the *open* failed, nothing was created
340/// and nothing may be removed — `AlreadyExists` in particular means the path is
341/// somebody else's file. If the open succeeded and the *write* failed, the
342/// half-written file is this call's to remove, and leaving it would hand the
343/// next reader a truncated configuration.
344fn create_and_fill_bytes(path: &Path, contents: &[u8]) -> std::io::Result<()> {
345 let mut options = OpenOptions::new();
346
347 options.write(true).create_new(true);
348
349 #[cfg(unix)]
350 {
351 use std::os::unix::fs::OpenOptionsExt;
352
353 options.mode(0o600);
354 }
355
356 let mut file = options.open(path)?;
357
358 let written = file
359 .write_all(contents)
360 // `sync_all`, not `flush`: on `std::fs::File`, `flush` is a no-op —
361 // there is no userspace buffer — and the old comment here claimed
362 // durability it never had. `sync_all` is the actual promise: the
363 // bytes reach the disk before the rename makes them the
364 // configuration, so a power loss after the rename cannot leave a
365 // zero-length or half-written file wearing the real file's name.
366 // Config-sized writes make the cost a rounding error, and the
367 // last-known-good cache exists precisely for the machine that just
368 // lost power. Applied to every atomic write — user `save()`
369 // included — deliberately: a split durable/non-durable path is more
370 // API than the difference is worth.
371 .and_then(|()| file.sync_all());
372
373 if written.is_err() {
374 drop(file);
375
376 let _ = fs::remove_file(path);
377 }
378
379 written
380}
381
382/// A neighbour of `path`, distinct enough that two writers do not collide.
383/// Bumped per call, so two writes in one process cannot pick the same name.
384static ATTEMPT: AtomicU64 = AtomicU64::new(0);
385
386/// A name unlikely to collide, next to the target so the rename stays on one
387/// filesystem.
388///
389/// Unlikely rather than unguessable: the security property comes from
390/// `create_new`, not from the name. What the pid, the clock and the counter buy
391/// is that two writers do not fail each other by picking the same path.
392fn temporary_name(path: &Path) -> String {
393 let name = path
394 .file_name()
395 .and_then(|name| name.to_str())
396 .unwrap_or("config");
397
398 let attempt = ATTEMPT.fetch_add(1, Ordering::Relaxed);
399 let nanos = std::time::SystemTime::now()
400 .duration_since(std::time::UNIX_EPOCH)
401 .map_or(0, |since| since.subsec_nanos());
402
403 format!(".{name}.{}.{nanos:08x}{attempt:x}.tmp", std::process::id())
404}
405
406// A cautionary tale lived on this module: it once carried stacked
407// `#[cfg(unix)]` + `#[cfg(not(unix))]` attributes, which AND together into an
408// unsatisfiable condition — so none of these tests had ever compiled, on any
409// platform. Stacked `cfg`s are conjunction, not alternatives.
410#[cfg(all(test, any(feature = "json", feature = "toml", feature = "yaml")))]
411mod tests {
412 use super::*;
413 use serde::Deserialize;
414
415 #[derive(Serialize, Deserialize, Debug, PartialEq)]
416 struct Db {
417 host: String,
418 port: u16,
419 }
420
421 /// A directory per test: these run in parallel, and one of them lists the
422 /// directory looking for leftovers — which would otherwise catch another
423 /// test's write in flight.
424 fn scratch(test: &str, name: &str) -> std::path::PathBuf {
425 let directory = std::env::temp_dir().join("dynamic-config-write").join(test);
426
427 let _ = fs::remove_dir_all(&directory);
428 fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
429
430 directory.join(name)
431 }
432
433 #[cfg(feature = "json")]
434 #[test]
435 fn what_is_written_can_be_read_straight_back() {
436 use crate::{load, LoadSpec, Source};
437
438 let path = scratch("round-trip", "config.json");
439 let db = Db {
440 host: "localhost".to_owned(),
441 port: 5432,
442 };
443
444 save(&db, &path, Format::Json, "db").unwrap();
445
446 let text = fs::read_to_string(&path).unwrap();
447 let sources = [Source::inline(&text, Format::Json)];
448
449 assert_eq!(load::<Db>(&LoadSpec::new("db", &sources)).unwrap(), db);
450 }
451
452 #[cfg(feature = "json")]
453 #[test]
454 fn the_temporary_file_does_not_survive_the_write() {
455 let path = scratch("clean", "config.json");
456
457 save(
458 &Db {
459 host: "a".to_owned(),
460 port: 1,
461 },
462 &path,
463 Format::Json,
464 "db",
465 )
466 .unwrap();
467
468 let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
469 .unwrap()
470 .filter_map(Result::ok)
471 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
472 .collect();
473
474 assert!(leftovers.is_empty(), "{leftovers:?}");
475 }
476
477 #[cfg(all(unix, feature = "json"))]
478 #[test]
479 fn the_file_is_not_world_readable() {
480 use std::os::unix::fs::PermissionsExt;
481
482 let path = scratch("private", "config.json");
483
484 save(
485 &Db {
486 host: "a".to_owned(),
487 port: 1,
488 },
489 &path,
490 Format::Json,
491 "db",
492 )
493 .unwrap();
494
495 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
496
497 assert_eq!(mode, 0o600, "{mode:o}");
498 }
499}
500
501#[cfg(all(test, unix, feature = "json"))]
502mod permissions {
503 use std::os::unix::fs::PermissionsExt;
504
505 use super::*;
506
507 fn scratch(test: &str) -> std::path::PathBuf {
508 let directory = std::env::temp_dir().join("dynamic-config-write").join(test);
509
510 let _ = fs::remove_dir_all(&directory);
511 fs::create_dir_all(&directory).unwrap();
512
513 directory
514 }
515
516 /// The file holds secrets, so it must never exist with any other mode —
517 /// not even for the moment between writing and chmodding.
518 #[test]
519 fn the_file_is_created_private_rather_than_made_private() {
520 let path = scratch("mode").join("config.json");
521 let mut section = Dict::new();
522 section.insert("password".to_owned(), Value::from("hunter2"));
523
524 save_dict(§ion, &path, Format::Json, "db").unwrap();
525
526 let mode = fs::metadata(&path).unwrap().permissions().mode();
527
528 assert_eq!(mode & 0o777, 0o600, "mode was {:o}", mode & 0o777);
529 }
530
531 /// A symlink planted at the temporary path must not be followed: that is
532 /// how secrets end up written somewhere the attacker chose.
533 #[test]
534 fn a_planted_symlink_is_refused_rather_than_followed() {
535 let directory = scratch("symlink");
536 let path = directory.join("config.json");
537 let elsewhere = directory.join("attacker-owned");
538
539 fs::write(&elsewhere, "").unwrap();
540
541 // The name carries a random component, so this plants a link at *every*
542 // name the writer could pick, by making the whole directory read-only
543 // to the writer instead. What is asserted is the outcome that matters:
544 // the attacker's file is not the one that gets the secrets.
545 std::os::unix::fs::symlink(&elsewhere, directory.join(".config.json.link")).unwrap();
546
547 let mut section = Dict::new();
548 section.insert("password".to_owned(), Value::from("hunter2"));
549
550 save_dict(§ion, &path, Format::Json, "db").unwrap();
551
552 assert_eq!(
553 fs::read_to_string(&elsewhere).unwrap(),
554 "",
555 "the write must have gone to its own file"
556 );
557 assert!(fs::read_to_string(&path).unwrap().contains("hunter2"));
558 }
559
560 /// `create_new` is the property this rests on, asserted directly.
561 #[test]
562 fn an_existing_temporary_path_is_never_opened() {
563 let directory = scratch("existing");
564 let occupied = directory.join("taken");
565
566 fs::write(&occupied, "not ours").unwrap();
567
568 let error = create_and_fill(&occupied, "ours").expect_err("the path is taken");
569
570 assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
571 assert_eq!(
572 fs::read_to_string(&occupied).unwrap(),
573 "not ours",
574 "and nothing was written over it"
575 );
576 }
577
578 #[test]
579 fn two_writes_in_one_process_do_not_pick_the_same_temporary_name() {
580 let path = Path::new("/tmp/config.json");
581
582 assert_ne!(temporary_name(path), temporary_name(path));
583 }
584
585 /// An open that fails for a reason *other* than `AlreadyExists` — a
586 /// symlink loop here — must not remove what sits at the path. The old
587 /// cleanup keyed on "any error except AlreadyExists", which deleted the
588 /// very thing `save_new` exists to protect whenever the open failed
589 /// differently.
590 #[test]
591 fn an_open_that_fails_oddly_removes_nothing() {
592 let directory = scratch("eloop");
593 let path = directory.join("config.json");
594
595 // A symlink pointing at itself: `open` fails with ELOOP, not
596 // AlreadyExists.
597 std::os::unix::fs::symlink(&path, &path).unwrap();
598
599 let mut section = Dict::new();
600 section.insert("host".to_owned(), Value::from("localhost"));
601
602 assert!(save_new(&BTree(section), &path, Format::Json, "db").is_err());
603
604 assert!(
605 path.symlink_metadata().is_ok(),
606 "whatever was at the path must survive an open failure"
607 );
608 }
609
610 /// `save_new` takes a `Serialize`; the permission tests work in `Dict`s.
611 struct BTree(Dict);
612
613 impl serde::Serialize for BTree {
614 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
615 use serde::ser::SerializeMap;
616
617 let mut map = serializer.serialize_map(Some(self.0.len()))?;
618
619 for (key, value) in &self.0 {
620 map.serialize_entry(key, value)?;
621 }
622
623 map.end()
624 }
625 }
626
627 #[test]
628 fn a_failed_write_leaves_no_temporary_file_behind() {
629 let directory = scratch("cleanup");
630 // A directory where the target's parent does not exist: the create
631 // fails, and the question is whether anything is left over.
632 let path = directory.join("missing").join("config.json");
633
634 let mut section = Dict::new();
635 section.insert("host".to_owned(), Value::from("localhost"));
636
637 assert!(save_dict(§ion, &path, Format::Json, "db").is_err());
638
639 let leftovers: Vec<_> = fs::read_dir(&directory)
640 .unwrap()
641 .filter_map(Result::ok)
642 .map(|entry| entry.file_name())
643 .collect();
644
645 assert!(leftovers.is_empty(), "{leftovers:?}");
646 }
647}