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