#![allow(clippy::all)]
use std::fs::{self, DirBuilder, File};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use chrono::Utc;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tracing::{error, info, warn};
use crate::audit::{AuditEvent, AuditLogger};
use crate::error::SynqroError;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub path: String,
pub sha256: String,
pub permissions: u32,
pub size_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotManifest {
pub version: String,
pub created_at: String,
pub files: Vec<FileEntry>,
pub manifest_hmac: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct Blacklist {
blacklisted: Vec<String>,
list_hmac: String,
}
pub fn take_snapshot(
version: &str,
target_paths: &[PathBuf],
backup_dir: &Path,
hmac_key: &[u8],
) -> Result<(), SynqroError> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(backup_dir)
.map_err(|e| SynqroError::Permission(format!("Cannot create backup dir: {}", e)))?;
}
#[cfg(not(unix))]
{
fs::create_dir_all(backup_dir)
.map_err(|e| SynqroError::Permission(format!("Cannot create backup dir: {}", e)))?;
}
let version_dir = backup_dir.join(format!("v{}", version));
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&version_dir)
.map_err(|e| {
SynqroError::Permission(format!("Cannot create version backup dir: {}", e))
})?;
}
#[cfg(not(unix))]
{
fs::create_dir_all(&version_dir).map_err(|e| {
SynqroError::Permission(format!("Cannot create version backup dir: {}", e))
})?;
}
let canonical_backup = version_dir
.canonicalize()
.map_err(|e| SynqroError::Permission(format!("Cannot canonicalize backup dir: {}", e)))?;
let mut file_entries: Vec<FileEntry> = Vec::new();
for src_path in target_paths {
let canonical_src = src_path.canonicalize().map_err(|e| {
SynqroError::Permission(format!("Cannot canonicalize source path: {}", e))
})?;
let file_name = canonical_src
.file_name()
.ok_or_else(|| SynqroError::InvalidInput("Source path has no file name".into()))?;
let dest_path = canonical_backup.join(file_name);
assert_path_under_parent(&dest_path, &canonical_backup)?;
let file_bytes = fs::read(&canonical_src)
.map_err(|e| SynqroError::Permission(format!("Cannot read source file: {}", e)))?;
let sha256 = hex::encode(Sha256::digest(&file_bytes));
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt;
fs::metadata(&canonical_src)
.map_err(|e| SynqroError::Permission(format!("Cannot stat file: {}", e)))?
.permissions()
.mode()
};
#[cfg(not(unix))]
let permissions: u32 = 0o644;
let size_bytes = file_bytes.len() as u64;
fs::write(&dest_path, &file_bytes)
.map_err(|e| SynqroError::Permission(format!("Cannot write backup file: {}", e)))?;
let relative_name = file_name.to_string_lossy().to_string();
file_entries.push(FileEntry {
path: relative_name,
sha256,
permissions,
size_bytes,
});
}
let created_at = Utc::now().to_rfc3339();
let unsigned = serde_json::json!({
"version": version,
"created_at": created_at,
"files": file_entries,
});
let canonical = canonical_json_sorted(&unsigned)?;
let manifest_hmac = compute_hmac(hmac_key, canonical.as_bytes())?;
let manifest = SnapshotManifest {
version: version.to_owned(),
created_at,
files: file_entries,
manifest_hmac,
};
let manifest_path = canonical_backup.join("snapshot.json");
let manifest_bytes = serde_json::to_vec_pretty(&manifest)
.map_err(|e| SynqroError::Internal(format!("Manifest serialise failed: {}", e)))?;
fs::write(&manifest_path, manifest_bytes)
.map_err(|e| SynqroError::Permission(format!("Cannot write snapshot.json: {}", e)))?;
info!(version = %version, files = manifest.files.len(), "Snapshot taken");
Ok(())
}
pub fn start_watchdog(
app_pid: u32,
health_check_timeout_secs: u64,
grace_period_secs: u64,
) -> Result<std::process::Child, SynqroError> {
let exe = std::env::current_exe().map_err(|e| {
SynqroError::Internal(format!("Cannot determine current executable path: {}", e))
})?;
let child = std::process::Command::new(&exe)
.args([
"--watchdog",
"--pid",
&app_pid.to_string(),
"--timeout",
&health_check_timeout_secs.to_string(),
"--grace",
&grace_period_secs.to_string(),
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| SynqroError::Internal(format!("Failed to spawn watchdog process: {}", e)))?;
info!(
watchdog_pid = child.id(),
app_pid = app_pid,
timeout = health_check_timeout_secs,
"Watchdog process started"
);
Ok(child)
}
pub fn rollback(
version: &str,
backup_dir: &Path,
hmac_key: &[u8],
audit: &AuditLogger,
) -> Result<(), SynqroError> {
let version_dir = backup_dir.join(format!("v{}", version));
let snapshot_path = version_dir.join("snapshot.json");
let snapshot_bytes = fs::read(&snapshot_path)
.map_err(|e| SynqroError::Rollback(format!("Cannot read snapshot.json: {}", e)))?;
let manifest: SnapshotManifest = serde_json::from_slice(&snapshot_bytes)
.map_err(|e| SynqroError::Rollback(format!("Snapshot.json is not valid JSON: {}", e)))?;
let unsigned = serde_json::json!({
"version": manifest.version,
"created_at": manifest.created_at,
"files": manifest.files,
});
let canonical = canonical_json_sorted(&unsigned)?;
let expected_hmac = compute_hmac(hmac_key, canonical.as_bytes())?;
if !constant_time_eq(expected_hmac.as_bytes(), manifest.manifest_hmac.as_bytes()) {
let _ = audit.log(
AuditEvent::BackupTampered,
serde_json::json!({ "version": version }),
);
error!(version = %version, "Snapshot HMAC invalid — backup may have been tampered with");
return Err(SynqroError::Rollback(
"Snapshot HMAC invalid — rollback aborted (backup tampered)".into(),
));
}
let canonical_backup = version_dir
.canonicalize()
.map_err(|e| SynqroError::Permission(format!("Cannot canonicalize backup dir: {}", e)))?;
for entry in &manifest.files {
let backup_file = canonical_backup.join(&entry.path);
assert_path_under_parent(&backup_file, &canonical_backup)?;
let backup_bytes = fs::read(&backup_file).map_err(|e| {
SynqroError::Rollback(format!("Cannot read backup file `{}`: {}", entry.path, e))
})?;
let actual_sha256 = hex::encode(Sha256::digest(&backup_bytes));
if !constant_time_eq(actual_sha256.as_bytes(), entry.sha256.as_bytes()) {
let _ = audit.log(
AuditEvent::BackupTampered,
serde_json::json!({
"version": version,
"file": entry.path,
"expected": entry.sha256,
"actual": actual_sha256,
}),
);
return Err(SynqroError::Rollback(format!(
"Backup file `{}` SHA-256 mismatch — backup tampered",
entry.path
)));
}
let dest = PathBuf::from(&entry.path);
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|e| {
SynqroError::Permission(format!(
"Cannot create parent dir for `{}`: {}",
entry.path, e
))
})?;
}
}
let temp_dest = dest.with_extension("synqro_restore.tmp");
fs::write(&temp_dest, &backup_bytes).map_err(|e| {
SynqroError::Rollback(format!(
"Cannot write restore temp for `{}`: {}",
entry.path, e
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(entry.permissions);
fs::set_permissions(&temp_dest, perms).map_err(|e| {
SynqroError::Permission(format!(
"Cannot set permissions on `{}`: {}",
entry.path, e
))
})?;
}
fs::rename(&temp_dest, &dest).map_err(|e| {
let _ = fs::remove_file(&temp_dest);
SynqroError::Rollback(format!("Atomic rename failed for `{}`: {}", entry.path, e))
})?;
}
audit.log(
AuditEvent::RollbackSuccess,
serde_json::json!({
"version": version,
"file_count": manifest.files.len(),
"ts": Utc::now().to_rfc3339(),
}),
)?;
info!(version = %version, "Rollback completed successfully");
let counter_path = backup_dir.join("rollback_counters.json");
let new_count = increment_rollback_counter(version, &counter_path)?;
if new_count >= 3 {
warn!(
version = %version,
count = new_count,
"Version hit rollback threshold — blacklisting"
);
let blacklist_path = backup_dir.join("blacklist.json");
let blist_hmac_key: Vec<u8> = hmac_key.to_vec();
blacklist_version(version, &blacklist_path, &blist_hmac_key, audit)?;
}
Ok(())
}
pub fn blacklist_version(
version: &str,
blacklist_path: &Path,
hmac_key: &[u8],
audit: &AuditLogger,
) -> Result<(), SynqroError> {
let mut bl = load_blacklist_raw(blacklist_path, hmac_key).unwrap_or_default();
if !bl.blacklisted.contains(&version.to_owned()) {
bl.blacklisted.push(version.to_owned());
bl.blacklisted.sort(); }
let unsigned = serde_json::json!({ "blacklisted": bl.blacklisted });
let canonical = canonical_json_sorted(&unsigned)?;
bl.list_hmac = compute_hmac(hmac_key, canonical.as_bytes())?;
write_json_atomic(blacklist_path, &bl)?;
audit.log(
AuditEvent::VersionBlacklisted,
serde_json::json!({ "version": version }),
)?;
warn!(version = %version, "Version blacklisted");
Ok(())
}
pub fn is_blacklisted(
version: &str,
blacklist_path: &Path,
hmac_key: &[u8],
) -> Result<bool, SynqroError> {
match load_blacklist_raw(blacklist_path, hmac_key) {
Ok(bl) => Ok(bl.blacklisted.iter().any(|v| v == version)),
Err(SynqroError::Permission(_)) => {
Ok(false)
}
Err(e) => Err(e),
}
}
fn load_blacklist_raw(path: &Path, hmac_key: &[u8]) -> Result<Blacklist, SynqroError> {
let bytes = fs::read(path)
.map_err(|e| SynqroError::Permission(format!("Cannot read blacklist: {}", e)))?;
let bl: Blacklist = serde_json::from_slice(&bytes)
.map_err(|e| SynqroError::InvalidInput(format!("Blacklist is not valid JSON: {}", e)))?;
let mut sorted = bl.blacklisted.clone();
sorted.sort();
let unsigned = serde_json::json!({ "blacklisted": sorted });
let canonical = canonical_json_sorted(&unsigned)?;
let expected = compute_hmac(hmac_key, canonical.as_bytes())?;
if !constant_time_eq(expected.as_bytes(), bl.list_hmac.as_bytes()) {
return Err(SynqroError::Crypto(
"Blacklist HMAC invalid — file may have been tampered with".into(),
));
}
Ok(bl)
}
fn increment_rollback_counter(version: &str, path: &Path) -> Result<u32, SynqroError> {
let mut counters: serde_json::Map<String, serde_json::Value> = if path.exists() {
let bytes = fs::read(path)
.map_err(|e| SynqroError::Internal(format!("Cannot read rollback counters: {}", e)))?;
serde_json::from_slice(&bytes).map_err(|e| {
SynqroError::Internal(format!("Rollback counters JSON parse error: {}", e))
})?
} else {
serde_json::Map::new()
};
let current = counters.get(version).and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let next = current.saturating_add(1);
counters.insert(version.to_owned(), serde_json::Value::from(next));
write_json_atomic(path, &serde_json::Value::Object(counters))?;
Ok(next)
}
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), SynqroError> {
let parent = path.parent().unwrap_or(Path::new("."));
let temp_path = parent.join(format!(
".synqro_tmp_{}.json",
Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
let json_bytes = serde_json::to_vec_pretty(value)
.map_err(|e| SynqroError::Internal(format!("JSON serialise failed: {}", e)))?;
let mut temp_file = File::create(&temp_path)
.map_err(|e| SynqroError::Permission(format!("Cannot create temp file: {}", e)))?;
temp_file
.write_all(&json_bytes)
.map_err(|e| SynqroError::Internal(format!("Temp file write failed: {}", e)))?;
temp_file
.flush()
.map_err(|e| SynqroError::Internal(format!("Temp file flush failed: {}", e)))?;
temp_file
.sync_all()
.map_err(|e| SynqroError::Internal(format!("Temp file fsync failed: {}", e)))?;
drop(temp_file);
fs::rename(&temp_path, path).map_err(|e| {
let _ = fs::remove_file(&temp_path);
SynqroError::Permission(format!("Atomic rename failed: {}", e))
})?;
Ok(())
}
fn assert_path_under_parent(path: &Path, parent: &Path) -> Result<(), SynqroError> {
let Ok(relative) = path.strip_prefix(parent) else {
return Err(SynqroError::InvalidInput(format!(
"Path traversal detected: {:?} is not under {:?}",
path, parent
)));
};
for component in relative.components() {
if component == Component::ParentDir {
return Err(SynqroError::InvalidInput(
"Path traversal: `..` component detected in relative path".into(),
));
}
}
Ok(())
}
fn canonical_json_sorted(value: &serde_json::Value) -> Result<String, SynqroError> {
let sorted = sort_json_keys(value);
serde_json::to_string(&sorted)
.map_err(|e| SynqroError::Internal(format!("Canonical JSON failed: {}", e)))
}
fn sort_json_keys(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted: Vec<(String, serde_json::Value)> = map
.iter()
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
.collect();
sorted.sort_by(|(a, _), (b, _)| a.cmp(b));
serde_json::Value::Object(sorted.into_iter().collect())
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(sort_json_keys).collect())
}
other => other.clone(),
}
}
fn compute_hmac(key: &[u8], data: &[u8]) -> Result<String, SynqroError> {
let mut mac = HmacSha256::new_from_slice(key)
.map_err(|e| SynqroError::Crypto(format!("HMAC init failed: {}", e)))?;
mac.update(data);
Ok(hex::encode(mac.finalize().into_bytes()))
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}