use std::ffi::OsString;
use std::fmt;
use std::fs;
use std::io::{self, Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use zeroize::Zeroizing;
use crate::credentials::Secret;
const HEADER: &[u8] = b"bevy_net_backend secret file 1\n";
const MAX_FILE_BYTES: u64 = 64 * 1024;
#[derive(Clone, PartialEq, Eq)]
pub struct SecretFile {
path: PathBuf,
}
impl fmt::Debug for SecretFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SecretFile").field("path", &self.path).finish()
}
}
impl SecretFile {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
fn fail(&self, kind: io::ErrorKind, what: &str, detail: impl fmt::Display) -> io::Error {
io::Error::new(kind, format!("secret file `{}`: {what}: {detail}", self.path.display()))
}
pub fn load(&self) -> io::Result<Option<Secret>> {
let mut file = match fs::File::open(&self.path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(self.fail(e.kind(), "could not be opened", e)),
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if file.metadata().is_ok_and(|meta| meta.permissions().mode() & 0o077 != 0) {
tracing::warn!(">>> NET-BACKEND: secret file `{}` can be read by other users; the next save writes it owner-only (0600)", self.path.display());
}
}
let limit = usize::try_from(MAX_FILE_BYTES).unwrap_or(usize::MAX);
let mut bytes = Zeroizing::new(Vec::with_capacity(limit.saturating_add(1)));
(&mut file).take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes).map_err(|e| self.fail(e.kind(), "could not be read", e))?;
if bytes.len() > limit {
return Err(self.fail(io::ErrorKind::InvalidData, "not a secret file", format_args!("larger than {MAX_FILE_BYTES} bytes")));
}
let Some(body) = bytes.strip_prefix(HEADER) else {
return Err(self.fail(io::ErrorKind::InvalidData, "not a secret file", "damaged, or not written by SecretFile"));
};
let text = std::str::from_utf8(body).map_err(|_| self.fail(io::ErrorKind::InvalidData, "not a secret file", "the secret is not UTF-8"))?;
Ok(Some(Secret::new(text)))
}
pub fn save(&self, secret: &Secret) -> io::Result<()> {
let mut bytes = Zeroizing::new(Vec::with_capacity(HEADER.len() + secret.expose().len()));
bytes.extend_from_slice(HEADER);
bytes.extend_from_slice(secret.expose().as_bytes());
if bytes.len() as u64 > MAX_FILE_BYTES {
return Err(self.fail(io::ErrorKind::InvalidInput, "not written", format_args!("the secret is larger than {MAX_FILE_BYTES} bytes")));
}
let dir = match self.path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
_ => PathBuf::from("."),
};
create_dir(&dir).map_err(|e| self.fail(e.kind(), "its folder could not be created", e))?;
let name = self.path.file_name().ok_or_else(|| self.fail(io::ErrorKind::InvalidInput, "not a file path", "it has no file name"))?;
static COUNTER: AtomicU64 = AtomicU64::new(0);
let mut temp_name = OsString::from(".");
temp_name.push(name);
temp_name.push(format!(".{}.{}.tmp", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed)));
let temp = dir.join(temp_name);
let written = (|| {
let mut file = create_owner_only(&temp)?;
file.write_all(&bytes)?;
file.sync_all()?;
drop(file);
fs::rename(&temp, &self.path)
})();
if let Err(e) = written {
let _ = fs::remove_file(&temp);
return Err(self.fail(e.kind(), "could not be written", e));
}
sync_dir(&dir);
Ok(())
}
pub fn remove(&self) -> io::Result<()> {
match fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(self.fail(e.kind(), "could not be removed", e)),
}
}
}
fn create_owner_only(path: &Path) -> io::Result<fs::File> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path)
}
fn create_dir(dir: &Path) -> io::Result<()> {
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(dir)
}
pub(crate) fn sync_dir(_dir: &Path) {
#[cfg(unix)]
if let Ok(dir) = fs::File::open(_dir) {
let _ = dir.sync_all();
}
}