Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/audit/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ This document freezes the established user-facing contract of `server_manager`.
| `/opt/server_manager/config.yaml` | YAML | `0644` | System configuration (domain, port bindings, profile, enabled services). |
| `/opt/server_manager/secrets.yaml` | YAML | `0600` | Administrative credentials, database passwords, API tokens. |
| `/opt/server_manager/users.yaml` | YAML | `0600` | User account database (usernames, password hashes, roles, quotas, installed apps). |
| `/opt/server_manager/docker-compose.yml` | YAML | `0644` | Generated Docker Compose stack definition. |
| `/opt/server_manager/docker-compose.yml` | YAML | `0600` | Generated Docker Compose stack definition. |

---

Expand Down
113 changes: 50 additions & 63 deletions server_manager/src/core/atomic_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,81 +4,68 @@ use std::io::Write;
use std::path::Path;

#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

/// Atomically writes content to a file.
///
/// Steps:
/// 1. Creates a temporary file in the same directory as `path` (guaranteeing same filesystem/mount).
/// 2. Sets explicit permissions on creation (e.g. 0600 or 0644 on Unix).
/// 3. Writes content and flushes buffers.
/// 4. Synchronizes to disk via `fsync` (`sync_all`).
/// 5. Atomically renames the temporary file to the destination path.
/// Persist a complete file with explicit permissions and file + directory fsync.
/// Callers must additionally lock an entire read-modify-write transaction.
pub fn atomic_write<P: AsRef<Path>>(path: P, content: &[u8], mode: u32) -> Result<()> {
let dest = path.as_ref();
let parent = dest.parent().unwrap_or_else(|| Path::new("."));
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create parent directory {}", parent.display()))?;
}

let file_name = dest
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "temp_file".to_string());

let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();

let tmp_name = format!(".tmp.{}.{}.{}", file_name, pid, nanos);
let tmp_path = parent.join(tmp_name);

let write_result = (|| -> Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);

#[cfg(unix)]
options.mode(mode);

let mut file = options
.open(&tmp_path)
.with_context(|| format!("Failed to create temporary file {}", tmp_path.display()))?;
let parent = dest
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let name = dest.file_name().context("Destination must name a file")?;
fs::create_dir_all(parent).context("Failed to create output directory")?;
let lock_path = parent.join(format!(".{}.lock", name.to_string_lossy()));
let _lock = crate::core::lock::ProcessLock::acquire(lock_path, false)?;

file.write_all(content)
.with_context(|| format!("Failed to write content to {}", tmp_path.display()))?;
file.flush()
.with_context(|| format!("Failed to flush temporary file {}", tmp_path.display()))?;
file.sync_all()
.with_context(|| format!("Failed to fsync temporary file {}", tmp_path.display()))?;

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(mode));
// Preserve timestamps on idempotent writes; never follow destination symlinks.
if let Ok(meta) = fs::symlink_metadata(dest) {
if meta.is_file() && fs::read(dest).is_ok_and(|old| old == content) {
#[cfg(unix)]
if meta.permissions().mode() & 0o777 != mode {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(dest)?;
file.set_permissions(fs::Permissions::from_mode(mode))?;
file.sync_all()?;
}
return Ok(());
}
}

fs::rename(&tmp_path, dest).with_context(|| {
format!(
"Failed to atomically rename {} to {}",
tmp_path.display(),
dest.display()
)
})?;

let tmp_path = parent.join(format!(
".tmp.{}.{:032x}",
name.to_string_lossy(),
rand::random::<u128>()
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(mode);
// Only remove a temporary file after this process successfully created it.
let mut file = options
.open(&tmp_path)
.context("Failed to create temporary output")?;
let result = (|| -> Result<()> {
#[cfg(unix)]
file.set_permissions(fs::Permissions::from_mode(mode))
.context("Failed to set output permissions")?;
file.write_all(content).context("Failed to write output")?;
file.sync_all().context("Failed to fsync output")?;
fs::rename(&tmp_path, dest).context("Failed to atomically replace output")?;
fs::File::open(parent)?
.sync_all()
.context("Failed to fsync output directory")?;
Ok(())
})();

if write_result.is_err() && tmp_path.exists() {
if result.is_err() {
let _ = fs::remove_file(&tmp_path);
}

write_result
result
}

/// Helper function to atomically write a string slice.
pub fn atomic_write_str<P: AsRef<Path>>(path: P, content: &str, mode: u32) -> Result<()> {
atomic_write(path, content.as_bytes(), mode)
}
Loading
Loading