use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use figment::value::{Dict, Value};
use serde::Serialize;
use crate::error::{Error, ErrorKind, Origin};
use crate::source::Format;
pub fn save<T: Serialize>(
value: &T,
path: impl AsRef<Path>,
format: Format,
key: &str,
) -> Result<(), Error> {
let path = path.as_ref();
write_atomically(path, &render(&document_of(value, key)?, format)?)
}
pub(crate) fn save_dict(
section: &Dict,
path: &Path,
format: Format,
key: &str,
) -> Result<(), Error> {
let mut document = Dict::new();
document.insert(key.to_owned(), Value::from(section.clone()));
let rendered = render(&document, format)?;
write_atomically(path, &rendered)
}
fn render(document: &Dict, format: Format) -> Result<String, Error> {
#[cfg(not(any(feature = "json", feature = "toml", feature = "yaml")))]
let _ = document;
match format {
#[cfg(feature = "json")]
Format::Json => serde_json::to_string_pretty(document)
.map(|mut rendered| {
rendered.push('\n');
rendered
})
.map_err(serialization),
#[cfg(feature = "toml")]
Format::Toml => toml::to_string_pretty(document).map_err(serialization),
#[cfg(feature = "yaml")]
Format::Yaml => serde_yaml::to_string(document).map_err(serialization),
#[allow(unreachable_patterns)]
format => Err(Error::new(
ErrorKind::Backend,
format!(
"cannot write {format:?} because the `{}` feature is not enabled",
format.feature()
),
)),
}
}
#[allow(dead_code)]
fn serialization(error: impl std::fmt::Display) -> Error {
Error::new(ErrorKind::Type, error.to_string())
}
pub fn save_new<T: Serialize>(
value: &T,
path: impl AsRef<Path>,
format: Format,
key: &str,
) -> Result<(), Error> {
let path = path.as_ref();
let rendered = render(&document_of(value, key)?, format)?;
create_and_fill(path, &rendered).map_err(|error| {
Error::new(ErrorKind::Io, error.to_string()).with_origin(Origin::File(path.to_owned()))
})
}
#[cfg(feature = "decrypt")]
#[cfg_attr(docsrs, doc(cfg(feature = "decrypt")))]
pub fn save_encrypted<T: Serialize>(
value: &T,
path: impl AsRef<Path>,
format: Format,
key: &str,
encryptor: &dyn crate::Encryptor,
) -> Result<(), Error> {
let path = path.as_ref();
let rendered = render(&document_of(value, key)?, format)?;
let ciphertext = encryptor
.encrypt(rendered.as_bytes())
.map_err(|error| error.prepend_key(encryptor.describe()))?;
write_bytes_atomically(path, &ciphertext)
}
fn document_of<T: Serialize>(value: &T, key: &str) -> Result<Dict, Error> {
let section =
Value::serialize(value).map_err(|error| Error::new(ErrorKind::Type, error.to_string()))?;
let Value::Dict(_, section) = section else {
return Err(Error::new(
ErrorKind::Type,
format!("a configuration section must be a table, not {section:?}"),
));
};
let mut document = Dict::new();
document.insert(key.to_owned(), Value::from(section));
Ok(document)
}
fn write_atomically(path: &Path, contents: &str) -> Result<(), Error> {
write_bytes_atomically(path, contents.as_bytes())
}
fn write_bytes_atomically(path: &Path, contents: &[u8]) -> Result<(), Error> {
let directory = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty());
let temporary = match directory {
Some(directory) => directory.join(temporary_name(path)),
None => Path::new(".").join(temporary_name(path)),
};
let io = |error: std::io::Error| {
Error::new(ErrorKind::Io, error.to_string()).with_origin(Origin::File(path.to_owned()))
};
create_and_fill_bytes(&temporary, contents).map_err(io)?;
fs::rename(&temporary, path).map_err(|error| {
let _ = fs::remove_file(&temporary);
io(error)
})
}
fn create_and_fill(temporary: &Path, contents: &str) -> std::io::Result<()> {
create_and_fill_bytes(temporary, contents.as_bytes())
}
fn create_and_fill_bytes(path: &Path, contents: &[u8]) -> std::io::Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
let written = file
.write_all(contents)
.and_then(|()| file.flush());
if written.is_err() {
drop(file);
let _ = fs::remove_file(path);
}
written
}
static ATTEMPT: AtomicU64 = AtomicU64::new(0);
fn temporary_name(path: &Path) -> String {
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("config");
let attempt = ATTEMPT.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.subsec_nanos());
format!(".{name}.{}.{nanos:08x}{attempt:x}.tmp", std::process::id())
}
#[cfg(all(test, any(feature = "json", feature = "toml", feature = "yaml")))]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Db {
host: String,
port: u16,
}
fn scratch(test: &str, name: &str) -> std::path::PathBuf {
let directory = std::env::temp_dir().join("dynamic-config-write").join(test);
let _ = fs::remove_dir_all(&directory);
fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
directory.join(name)
}
#[cfg(feature = "json")]
#[test]
fn what_is_written_can_be_read_straight_back() {
use crate::{load, LoadSpec, Source};
let path = scratch("round-trip", "config.json");
let db = Db {
host: "localhost".to_owned(),
port: 5432,
};
save(&db, &path, Format::Json, "db").unwrap();
let text = fs::read_to_string(&path).unwrap();
let sources = [Source::inline(&text, Format::Json)];
assert_eq!(load::<Db>(&LoadSpec::new("db", &sources)).unwrap(), db);
}
#[cfg(feature = "json")]
#[test]
fn the_temporary_file_does_not_survive_the_write() {
let path = scratch("clean", "config.json");
save(
&Db {
host: "a".to_owned(),
port: 1,
},
&path,
Format::Json,
"db",
)
.unwrap();
let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
#[cfg(all(unix, feature = "json"))]
#[test]
fn the_file_is_not_world_readable() {
use std::os::unix::fs::PermissionsExt;
let path = scratch("private", "config.json");
save(
&Db {
host: "a".to_owned(),
port: 1,
},
&path,
Format::Json,
"db",
)
.unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "{mode:o}");
}
}
#[cfg(all(test, unix, feature = "json"))]
mod permissions {
use std::os::unix::fs::PermissionsExt;
use super::*;
fn scratch(test: &str) -> std::path::PathBuf {
let directory = std::env::temp_dir().join("dynamic-config-write").join(test);
let _ = fs::remove_dir_all(&directory);
fs::create_dir_all(&directory).unwrap();
directory
}
#[test]
fn the_file_is_created_private_rather_than_made_private() {
let path = scratch("mode").join("config.json");
let mut section = Dict::new();
section.insert("password".to_owned(), Value::from("hunter2"));
save_dict(§ion, &path, Format::Json, "db").unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "mode was {:o}", mode & 0o777);
}
#[test]
fn a_planted_symlink_is_refused_rather_than_followed() {
let directory = scratch("symlink");
let path = directory.join("config.json");
let elsewhere = directory.join("attacker-owned");
fs::write(&elsewhere, "").unwrap();
std::os::unix::fs::symlink(&elsewhere, directory.join(".config.json.link")).unwrap();
let mut section = Dict::new();
section.insert("password".to_owned(), Value::from("hunter2"));
save_dict(§ion, &path, Format::Json, "db").unwrap();
assert_eq!(
fs::read_to_string(&elsewhere).unwrap(),
"",
"the write must have gone to its own file"
);
assert!(fs::read_to_string(&path).unwrap().contains("hunter2"));
}
#[test]
fn an_existing_temporary_path_is_never_opened() {
let directory = scratch("existing");
let occupied = directory.join("taken");
fs::write(&occupied, "not ours").unwrap();
let error = create_and_fill(&occupied, "ours").expect_err("the path is taken");
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(
fs::read_to_string(&occupied).unwrap(),
"not ours",
"and nothing was written over it"
);
}
#[test]
fn two_writes_in_one_process_do_not_pick_the_same_temporary_name() {
let path = Path::new("/tmp/config.json");
assert_ne!(temporary_name(path), temporary_name(path));
}
#[test]
fn an_open_that_fails_oddly_removes_nothing() {
let directory = scratch("eloop");
let path = directory.join("config.json");
std::os::unix::fs::symlink(&path, &path).unwrap();
let mut section = Dict::new();
section.insert("host".to_owned(), Value::from("localhost"));
assert!(save_new(&BTree(section), &path, Format::Json, "db").is_err());
assert!(
path.symlink_metadata().is_ok(),
"whatever was at the path must survive an open failure"
);
}
struct BTree(Dict);
impl serde::Serialize for BTree {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (key, value) in &self.0 {
map.serialize_entry(key, value)?;
}
map.end()
}
}
#[test]
fn a_failed_write_leaves_no_temporary_file_behind() {
let directory = scratch("cleanup");
let path = directory.join("missing").join("config.json");
let mut section = Dict::new();
section.insert("host".to_owned(), Value::from("localhost"));
assert!(save_dict(§ion, &path, Format::Json, "db").is_err());
let leftovers: Vec<_> = fs::read_dir(&directory)
.unwrap()
.filter_map(Result::ok)
.map(|entry| entry.file_name())
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
}