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