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
74fn render(document: &Dict, format: Format) -> Result<String, Error> {
75 #[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
110pub 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(path, &rendered).map_err(|error| {
138 Error::new(ErrorKind::Io, error.to_string()).with_origin(Origin::File(path.to_owned()))
139 })
140}
141
142#[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 mut rendered = render(&document_of(value, key)?, format)?;
183
184 let encrypted = encryptor
185 .encrypt(rendered.as_bytes())
186 .map_err(|error| error.prepend_key(encryptor.describe()));
187
188 {
192 use zeroize::Zeroize;
193
194 rendered.zeroize();
195 }
196
197 write_bytes_atomically(path, &encrypted?)
198}
199
200fn document_of<T: Serialize>(value: &T, key: &str) -> Result<Dict, Error> {
202 let section =
203 Value::serialize(value).map_err(|error| Error::new(ErrorKind::Type, error.to_string()))?;
204
205 let Value::Dict(_, section) = section else {
206 return Err(Error::new(
207 ErrorKind::Type,
208 format!("a configuration section must be a table, not {section:?}"),
209 ));
210 };
211
212 let mut document = Dict::new();
213 document.insert(key.to_owned(), Value::from(section));
214
215 Ok(document)
216}
217
218fn write_atomically(path: &Path, contents: &str) -> Result<(), Error> {
223 write_bytes_atomically(path, contents.as_bytes())
224}
225
226fn write_bytes_atomically(path: &Path, contents: &[u8]) -> Result<(), Error> {
227 let directory = path
228 .parent()
229 .filter(|parent| !parent.as_os_str().is_empty());
230 let temporary = match directory {
231 Some(directory) => directory.join(temporary_name(path)),
232 None => Path::new(".").join(temporary_name(path)),
233 };
234
235 let io = |error: std::io::Error| {
236 Error::new(ErrorKind::Io, error.to_string()).with_origin(Origin::File(path.to_owned()))
237 };
238
239 create_and_fill_bytes(&temporary, contents).map_err(io)?;
240
241 fs::rename(&temporary, path).map_err(|error| {
242 let _ = fs::remove_file(&temporary);
245
246 io(error)
247 })?;
248
249 #[cfg(unix)]
255 if let Some(directory) = directory {
256 if let Ok(handle) = fs::File::open(directory) {
257 let _ = handle.sync_all();
258 }
259 }
260
261 Ok(())
262}
263
264fn create_and_fill(temporary: &Path, contents: &str) -> std::io::Result<()> {
279 create_and_fill_bytes(temporary, contents.as_bytes())
280}
281
282fn create_and_fill_bytes(path: &Path, contents: &[u8]) -> std::io::Result<()> {
291 let mut options = OpenOptions::new();
292
293 options.write(true).create_new(true);
294
295 #[cfg(unix)]
296 {
297 use std::os::unix::fs::OpenOptionsExt;
298
299 options.mode(0o600);
300 }
301
302 let mut file = options.open(path)?;
303
304 let written = file
305 .write_all(contents)
306 .and_then(|()| file.sync_all());
318
319 if written.is_err() {
320 drop(file);
321
322 let _ = fs::remove_file(path);
323 }
324
325 written
326}
327
328static ATTEMPT: AtomicU64 = AtomicU64::new(0);
331
332fn temporary_name(path: &Path) -> String {
339 let name = path
340 .file_name()
341 .and_then(|name| name.to_str())
342 .unwrap_or("config");
343
344 let attempt = ATTEMPT.fetch_add(1, Ordering::Relaxed);
345 let nanos = std::time::SystemTime::now()
346 .duration_since(std::time::UNIX_EPOCH)
347 .map_or(0, |since| since.subsec_nanos());
348
349 format!(".{name}.{}.{nanos:08x}{attempt:x}.tmp", std::process::id())
350}
351
352#[cfg(all(test, any(feature = "json", feature = "toml", feature = "yaml")))]
357mod tests {
358 use super::*;
359 use serde::Deserialize;
360
361 #[derive(Serialize, Deserialize, Debug, PartialEq)]
362 struct Db {
363 host: String,
364 port: u16,
365 }
366
367 fn scratch(test: &str, name: &str) -> std::path::PathBuf {
371 let directory = std::env::temp_dir().join("dynamic-config-write").join(test);
372
373 let _ = fs::remove_dir_all(&directory);
374 fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
375
376 directory.join(name)
377 }
378
379 #[cfg(feature = "json")]
380 #[test]
381 fn what_is_written_can_be_read_straight_back() {
382 use crate::{load, LoadSpec, Source};
383
384 let path = scratch("round-trip", "config.json");
385 let db = Db {
386 host: "localhost".to_owned(),
387 port: 5432,
388 };
389
390 save(&db, &path, Format::Json, "db").unwrap();
391
392 let text = fs::read_to_string(&path).unwrap();
393 let sources = [Source::inline(&text, Format::Json)];
394
395 assert_eq!(load::<Db>(&LoadSpec::new("db", &sources)).unwrap(), db);
396 }
397
398 #[cfg(feature = "json")]
399 #[test]
400 fn the_temporary_file_does_not_survive_the_write() {
401 let path = scratch("clean", "config.json");
402
403 save(
404 &Db {
405 host: "a".to_owned(),
406 port: 1,
407 },
408 &path,
409 Format::Json,
410 "db",
411 )
412 .unwrap();
413
414 let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
415 .unwrap()
416 .filter_map(Result::ok)
417 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
418 .collect();
419
420 assert!(leftovers.is_empty(), "{leftovers:?}");
421 }
422
423 #[cfg(all(unix, feature = "json"))]
424 #[test]
425 fn the_file_is_not_world_readable() {
426 use std::os::unix::fs::PermissionsExt;
427
428 let path = scratch("private", "config.json");
429
430 save(
431 &Db {
432 host: "a".to_owned(),
433 port: 1,
434 },
435 &path,
436 Format::Json,
437 "db",
438 )
439 .unwrap();
440
441 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
442
443 assert_eq!(mode, 0o600, "{mode:o}");
444 }
445}
446
447#[cfg(all(test, unix, feature = "json"))]
448mod permissions {
449 use std::os::unix::fs::PermissionsExt;
450
451 use super::*;
452
453 fn scratch(test: &str) -> std::path::PathBuf {
454 let directory = std::env::temp_dir().join("dynamic-config-write").join(test);
455
456 let _ = fs::remove_dir_all(&directory);
457 fs::create_dir_all(&directory).unwrap();
458
459 directory
460 }
461
462 #[test]
465 fn the_file_is_created_private_rather_than_made_private() {
466 let path = scratch("mode").join("config.json");
467 let mut section = Dict::new();
468 section.insert("password".to_owned(), Value::from("hunter2"));
469
470 save_dict(§ion, &path, Format::Json, "db").unwrap();
471
472 let mode = fs::metadata(&path).unwrap().permissions().mode();
473
474 assert_eq!(mode & 0o777, 0o600, "mode was {:o}", mode & 0o777);
475 }
476
477 #[test]
480 fn a_planted_symlink_is_refused_rather_than_followed() {
481 let directory = scratch("symlink");
482 let path = directory.join("config.json");
483 let elsewhere = directory.join("attacker-owned");
484
485 fs::write(&elsewhere, "").unwrap();
486
487 std::os::unix::fs::symlink(&elsewhere, directory.join(".config.json.link")).unwrap();
492
493 let mut section = Dict::new();
494 section.insert("password".to_owned(), Value::from("hunter2"));
495
496 save_dict(§ion, &path, Format::Json, "db").unwrap();
497
498 assert_eq!(
499 fs::read_to_string(&elsewhere).unwrap(),
500 "",
501 "the write must have gone to its own file"
502 );
503 assert!(fs::read_to_string(&path).unwrap().contains("hunter2"));
504 }
505
506 #[test]
508 fn an_existing_temporary_path_is_never_opened() {
509 let directory = scratch("existing");
510 let occupied = directory.join("taken");
511
512 fs::write(&occupied, "not ours").unwrap();
513
514 let error = create_and_fill(&occupied, "ours").expect_err("the path is taken");
515
516 assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
517 assert_eq!(
518 fs::read_to_string(&occupied).unwrap(),
519 "not ours",
520 "and nothing was written over it"
521 );
522 }
523
524 #[test]
525 fn two_writes_in_one_process_do_not_pick_the_same_temporary_name() {
526 let path = Path::new("/tmp/config.json");
527
528 assert_ne!(temporary_name(path), temporary_name(path));
529 }
530
531 #[test]
537 fn an_open_that_fails_oddly_removes_nothing() {
538 let directory = scratch("eloop");
539 let path = directory.join("config.json");
540
541 std::os::unix::fs::symlink(&path, &path).unwrap();
544
545 let mut section = Dict::new();
546 section.insert("host".to_owned(), Value::from("localhost"));
547
548 assert!(save_new(&BTree(section), &path, Format::Json, "db").is_err());
549
550 assert!(
551 path.symlink_metadata().is_ok(),
552 "whatever was at the path must survive an open failure"
553 );
554 }
555
556 struct BTree(Dict);
558
559 impl serde::Serialize for BTree {
560 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
561 use serde::ser::SerializeMap;
562
563 let mut map = serializer.serialize_map(Some(self.0.len()))?;
564
565 for (key, value) in &self.0 {
566 map.serialize_entry(key, value)?;
567 }
568
569 map.end()
570 }
571 }
572
573 #[test]
574 fn a_failed_write_leaves_no_temporary_file_behind() {
575 let directory = scratch("cleanup");
576 let path = directory.join("missing").join("config.json");
579
580 let mut section = Dict::new();
581 section.insert("host".to_owned(), Value::from("localhost"));
582
583 assert!(save_dict(§ion, &path, Format::Json, "db").is_err());
584
585 let leftovers: Vec<_> = fs::read_dir(&directory)
586 .unwrap()
587 .filter_map(Result::ok)
588 .map(|entry| entry.file_name())
589 .collect();
590
591 assert!(leftovers.is_empty(), "{leftovers:?}");
592 }
593}