Skip to main content

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