1use 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
36pub 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
56pub(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#[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 {
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 #[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
139pub 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(path, &rendered).map_err(|error| {
167 Error::new(ErrorKind::Io, error.to_string()).with_origin(Origin::File(path.to_owned()))
168 })
169}
170
171#[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 {
221 use zeroize::Zeroize;
222
223 rendered.zeroize();
224 }
225
226 write_bytes_atomically(path, &encrypted?)
227}
228
229fn 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
247fn 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 let _ = fs::remove_file(&temporary);
274
275 io(error)
276 })?;
277
278 #[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
293fn create_and_fill(temporary: &Path, contents: &str) -> std::io::Result<()> {
308 create_and_fill_bytes(temporary, contents.as_bytes())
309}
310
311fn 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 .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
357static ATTEMPT: AtomicU64 = AtomicU64::new(0);
360
361fn 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#[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 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 #[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(§ion, &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 #[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 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(§ion, &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 #[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 #[test]
566 fn an_open_that_fails_oddly_removes_nothing() {
567 let directory = scratch("eloop");
568 let path = directory.join("config.json");
569
570 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 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 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(§ion, &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}