136 lines
4.4 KiB
Rust
136 lines
4.4 KiB
Rust
//! Same-directory atomic replacement shared by downloads and durable settings.
|
|
use std::{
|
|
ffi::OsString,
|
|
fs::{self, File, OpenOptions},
|
|
io::{self, Write},
|
|
path::{Path, PathBuf},
|
|
sync::atomic::{AtomicU64, Ordering},
|
|
};
|
|
|
|
static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// Owns a unique file until commit. Failed writes never replace the destination,
|
|
/// and dropping the transaction removes only the temporary file it created.
|
|
pub(crate) struct AtomicFile {
|
|
temporary: PathBuf,
|
|
target: PathBuf,
|
|
file: Option<File>,
|
|
committed: bool,
|
|
}
|
|
|
|
impl AtomicFile {
|
|
pub fn new(target: &Path) -> io::Result<Self> {
|
|
let parent = target
|
|
.parent()
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no parent"))?;
|
|
let name = target
|
|
.file_name()
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no filename"))?;
|
|
fs::create_dir_all(parent)?;
|
|
for _ in 0..128 {
|
|
let sequence = NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed);
|
|
let mut temporary_name = OsString::from(".");
|
|
temporary_name.push(name);
|
|
temporary_name.push(format!(".shacraft-{}-{sequence}.part", std::process::id()));
|
|
let temporary = parent.join(temporary_name);
|
|
let mut options = OpenOptions::new();
|
|
options.write(true).create_new(true);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
options.mode(0o600);
|
|
}
|
|
match options.open(&temporary) {
|
|
Ok(file) => {
|
|
return Ok(Self {
|
|
temporary,
|
|
target: target.to_path_buf(),
|
|
file: Some(file),
|
|
committed: false,
|
|
})
|
|
}
|
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
|
Err(error) => return Err(error),
|
|
}
|
|
}
|
|
Err(io::Error::new(
|
|
io::ErrorKind::AlreadyExists,
|
|
"cannot allocate a unique temporary file",
|
|
))
|
|
}
|
|
|
|
pub fn writer(&mut self) -> &mut File {
|
|
self.file
|
|
.as_mut()
|
|
.expect("atomic file is open until commit")
|
|
}
|
|
|
|
pub fn commit(mut self) -> io::Result<()> {
|
|
self.writer().sync_all()?;
|
|
drop(self.file.take());
|
|
fs::rename(&self.temporary, &self.target)?;
|
|
self.committed = true;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for AtomicFile {
|
|
fn drop(&mut self) {
|
|
drop(self.file.take());
|
|
if !self.committed {
|
|
let _ = fs::remove_file(&self.temporary);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn write_atomic(target: &Path, bytes: &[u8]) -> io::Result<()> {
|
|
let mut output = AtomicFile::new(target)?;
|
|
output.writer().write_all(bytes)?;
|
|
output.commit()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn competing_writers_do_not_share_temporary_files() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"shacraft-storage-test-{}-{}",
|
|
std::process::id(),
|
|
NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
let target = root.join("settings.json");
|
|
write_atomic(&target, b"original").unwrap();
|
|
let mut first = AtomicFile::new(&target).unwrap();
|
|
let mut second = AtomicFile::new(&target).unwrap();
|
|
assert_ne!(first.temporary, second.temporary);
|
|
first.writer().write_all(b"first").unwrap();
|
|
second.writer().write_all(b"second").unwrap();
|
|
first.commit().unwrap();
|
|
assert_eq!(fs::read(&target).unwrap(), b"first");
|
|
drop(second);
|
|
assert_eq!(fs::read(&target).unwrap(), b"first");
|
|
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
|
|
fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn persisted_secrets_are_owner_only() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let root = std::env::temp_dir().join(format!(
|
|
"shacraft-secret-test-{}-{}",
|
|
std::process::id(),
|
|
NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
let target = root.join("account.json");
|
|
write_atomic(&target, b"token").unwrap();
|
|
assert_eq!(
|
|
target.metadata().unwrap().permissions().mode() & 0o777,
|
|
0o600
|
|
);
|
|
fs::remove_dir_all(root).unwrap();
|
|
}
|
|
}
|