#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const NAME: &str = env!("CARGO_PKG_NAME");
pub mod config;
pub mod diff;
pub mod indent;
pub mod security;
pub use diff::generate_preview;
pub use indent::{auto_indent_content, needs_indent_fix, validate_indentation};
pub use config::DalLevel;
pub use config::SniperConfig;
pub use security::{normalize_path_secure, validate_path, PathSecurityError, SecurityPolicy};
use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, SystemTime};
use llmosafe::ResourceGuard;
use sha2::Digest;
pub const BACKUP_DIR: &str = ".sniper";
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ManifestOp {
pub start: usize,
#[serde(default)]
pub end: Option<usize>,
#[serde(default)]
pub hex: Option<String>,
#[serde(default)]
pub delete: Option<bool>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MemoryStats {
pub available_bytes: u64,
pub used_bytes: u64,
pub pressure: u8,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RiskTelemetry {
pub combined_risk_bits: u64,
pub classifier_score: f64,
pub memory_stats: MemoryStats,
}
impl RiskTelemetry {
pub fn from_guard(guard: &ResourceGuard) -> Self {
let entropy = guard.raw_entropy();
let pressure = guard.pressure();
let system_mem = ResourceGuard::system_memory_bytes() as u64;
let estimated_ceiling = system_mem / 2;
let used_bytes = estimated_ceiling * u64::from(pressure) / 100;
let classifier_score = sigmoid_f64(entropy as f64 / 1000.0);
Self {
combined_risk_bits: u64::from(entropy),
classifier_score,
memory_stats: MemoryStats {
available_bytes: system_mem,
used_bytes,
pressure,
},
}
}
}
pub fn recommend_from_risk(risk: &RiskTelemetry) -> String {
if risk.classifier_score >= 0.7 {
"Resource pressure high. Consider pausing.".into()
} else if risk.classifier_score >= 0.4 {
"Moderate load. Proceed with caution.".into()
} else {
"Resources nominal.".into()
}
}
fn sigmoid_f64(x: f64) -> f64 {
1.0 / (1.0 + (-x).exp())
}
#[derive(Debug, Clone)]
pub(crate) struct PidConfig {
pub base_ms: u64,
pub entropy_scale: f64,
pub pressure_scale: f64,
}
impl Default for PidConfig {
fn default() -> Self {
Self {
base_ms: 0,
entropy_scale: 0.5,
pressure_scale: 1.0,
}
}
}
impl PidConfig {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn sleep_duration(&self, entropy: u64, pressure: u8) -> Duration {
let ms = self.base_ms
+ ((entropy as f64) * self.entropy_scale) as u64
+ ((pressure as f64) * self.pressure_scale) as u64;
Duration::from_millis(ms)
}
}
pub fn hex_encode(data: &[u8]) -> String {
let mut buf = Vec::with_capacity(data.len() * 2);
for &b in data {
buf.push(HEX_CHARS[(b >> 4) as usize]);
buf.push(HEX_CHARS[(b & 0x0F) as usize]);
}
String::from_utf8_lossy(&buf).into_owned()
}
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
pub fn hex_decode(hex: &str) -> Result<String, String> {
let clean: String = hex.chars().filter(|c| !c.is_whitespace()).collect();
if !clean.len().is_multiple_of(2) {
return Err(format!("odd-length hex string: {}", clean.len()));
}
if let Some(c) = clean.chars().find(|c| !c.is_ascii_hexdigit()) {
return Err(format!("invalid hex character: '{c}'"));
}
let mut bytes = Vec::with_capacity(clean.len() / 2);
for i in (0..clean.len()).step_by(2) {
let res = u8::from_str_radix(&clean[i..i + 2], 16)
.map_err(|e| format!("hex decode at byte {}: {e}", i / 2))?;
bytes.push(res);
}
String::from_utf8(bytes).map_err(|e| format!("utf8 decode: {e}"))
}
pub fn normalize_path(path: &str) -> Result<PathBuf, String> {
let policy = SecurityPolicy::default();
validate_path(path, &policy).map_err(|e| e.to_string())
}
pub fn check_file_size(filepath: &str, max_size: u64) -> Result<(), String> {
if max_size == 0 {
return Ok(());
}
let metadata = fs::metadata(filepath)
.map_err(|e| format!("Failed to get metadata for {}: {}", filepath, e))?;
let size = metadata.len();
if size > max_size {
Err(format!(
"File too large: {} bytes (max: {} bytes). Use SNIPER_MAX_FILE_SIZE to increase limit.",
size, max_size
))
} else {
Ok(())
}
}
pub fn get_path_hash(path: &Path) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub fn create_backup(filepath: &str) -> Result<String, String> {
let normalized = normalize_path(filepath)?;
let hash = get_path_hash(&normalized);
let dir = PathBuf::from(BACKUP_DIR);
fs::create_dir_all(&dir).map_err(|e| format!("create backup dir: {e}"))?;
let name = normalized
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let ts = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos())
.map_err(|e| format!("timestamp: {e}"))?;
let backup_name = format!("{hash}.{name}.{ts}");
let dst = dir.join(&backup_name);
if normalized.exists() {
fs::copy(&normalized, &dst).map_err(|e| format!("backup copy: {e}"))?;
} else {
fs::File::create(&dst).map_err(|e| format!("create empty backup: {e}"))?;
}
Ok(dst.to_string_lossy().into())
}
pub fn purge_old_backups(filepath: &str, config: &SniperConfig) -> Result<(), String> {
if config.backup_retention_count == 0 && config.backup_max_age_days == 0 {
return Ok(());
}
let normalized = normalize_path(filepath)?;
let hash = get_path_hash(&normalized);
let dir = PathBuf::from(BACKUP_DIR);
if !dir.exists() {
return Ok(());
}
let mut backups: Vec<_> = fs::read_dir(&dir)
.map_err(|e| format!("read backup dir: {e}"))?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&hash))
.filter_map(|e| {
let path = e.path();
let modified = e.metadata().ok()?.modified().ok()?;
Some((path, modified))
})
.collect();
backups.sort_by_key(|(_, modified)| *modified);
let now = SystemTime::now();
let max_age = if config.backup_max_age_days > 0 {
Some(Duration::from_secs(
config.backup_max_age_days * 24 * 60 * 60,
))
} else {
None
};
let mut to_delete = HashSet::new();
if let Some(max_age_duration) = max_age {
for (path, modified) in &backups {
if now.duration_since(*modified).unwrap_or(Duration::ZERO) > max_age_duration {
to_delete.insert(path);
}
}
}
if config.backup_retention_count > 0 && backups.len() > config.backup_retention_count {
let to_remove = backups.len() - config.backup_retention_count;
for (path, _) in backups.iter().take(to_remove) {
to_delete.insert(path);
}
}
for path in &to_delete {
match fs::remove_file(path) {
Ok(()) => eprintln!("[SNIPER] Purged old backup: {:?}", path),
Err(e) => eprintln!("[SNIPER] Failed to purge backup {:?}: {e}", path),
}
}
Ok(())
}
pub fn find_latest_backup(filepath: &str) -> Result<Option<PathBuf>, String> {
let normalized = normalize_path(filepath)?;
let hash = get_path_hash(&normalized);
let dir = PathBuf::from(BACKUP_DIR);
if !dir.exists() {
return Ok(None);
}
let latest_backup = fs::read_dir(dir)
.map_err(|e| format!("read backup dir: {e}"))?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&hash))
.map(|e| e.path())
.max();
Ok(latest_backup)
}
pub fn write_atomic(filepath: &str, lines: &[&str]) -> Result<(), String> {
let guard = ResourceGuard::auto(0.5);
let has_trailing_newline = check_trailing_newline(filepath)?;
write_atomic_impl(filepath, lines, has_trailing_newline, &guard)
}
pub fn write_atomic_with_dal(
filepath: &str,
lines: &[&str],
guard: &ResourceGuard,
dal_level: DalLevel,
) -> Result<(), String> {
guard.check().map_err(|e| format!("resource safety: {e}"))?;
if dal_level == DalLevel::Maximum {
guard
.check()
.map_err(|e| format!("resource safety (DAL maximum): {e}"))?;
}
let has_trailing_newline = check_trailing_newline(filepath)?;
write_atomic_impl(filepath, lines, has_trailing_newline, guard)
}
fn check_trailing_newline(filepath: &str) -> Result<bool, String> {
use std::io::{Read, Seek, SeekFrom};
let mut f = match fs::File::open(filepath) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(format!("open {filepath}: {e}")),
};
let metadata = f
.metadata()
.map_err(|e| format!("metadata {filepath}: {e}"))?;
if metadata.len() == 0 {
return Ok(false);
}
if f.seek(SeekFrom::End(-1)).is_err() {
return Ok(false);
}
let mut last_byte = [0u8; 1];
if f.read_exact(&mut last_byte).is_err() {
return Ok(false);
}
Ok(last_byte[0] == b'\n')
}
fn write_atomic_impl<S: AsRef<str>>(
filepath: &str,
lines: &[S],
has_trailing_newline: bool,
guard: &ResourceGuard,
) -> Result<(), String> {
let ts = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = format!("{filepath}.sniper_tmp.{ts}");
struct CleanupGuard<'a> {
path: &'a str,
active: bool,
}
impl<'a> CleanupGuard<'a> {
fn new(path: &'a str) -> Self {
Self { path, active: true }
}
fn disarm(&mut self) {
self.active = false;
}
}
impl<'a> Drop for CleanupGuard<'a> {
fn drop(&mut self) {
if self.active {
let _ = fs::remove_file(self.path);
}
}
}
let mut cleanup_guard = CleanupGuard::new(&tmp);
let f = fs::File::create(&tmp).map_err(|e| format!("create tmp: {e}"))?;
if let Ok(metadata) = fs::metadata(filepath) {
let perms = metadata.permissions();
if let Err(e) = fs::set_permissions(&tmp, perms) {
eprintln!(
"[SNIPER] Warning: failed to preserve file permissions for {:?}: {e}",
filepath
);
}
}
let mut f = std::io::BufWriter::new(f);
let num_lines = lines.len();
for (i, line) in lines.iter().enumerate() {
let mut bytes = line.as_ref().as_bytes();
if bytes.ends_with(b"\n") {
bytes = &bytes[..bytes.len() - 1];
}
f.write_all(bytes).map_err(|e| format!("write: {e}"))?;
let is_last = i == num_lines - 1;
if !is_last || has_trailing_newline {
f.write_all(b"\n")
.map_err(|e| format!("write newline: {e}"))?;
}
}
f.into_inner().map_err(|e| format!("flush: {e}"))?;
let entropy = guard.raw_entropy();
let pressure = guard.pressure();
let config = SniperConfig::from_env();
let pid = PidConfig {
base_ms: config.pid_base_ms,
entropy_scale: config.pid_entropy_scale,
pressure_scale: config.pid_pressure_scale,
};
let sleep = pid.sleep_duration(u64::from(entropy), pressure);
if !sleep.is_zero() {
thread::sleep(sleep);
}
match fs::rename(&tmp, filepath) {
Ok(_) => {
cleanup_guard.disarm();
Ok(())
}
Err(e) => Err(handle_backtrack_error(e, "Atomic write")),
}
}
pub fn compute_context_hash(lines: &[String], start: usize, end: usize) -> String {
let before_start = start.saturating_sub(1).saturating_sub(3);
let before_end = (start.saturating_sub(1)).min(lines.len());
let after_start = end;
let after_end = (end + 3).min(lines.len());
let mut hasher = sha2::Sha256::new();
for i in before_start..before_end {
if i < lines.len() {
hasher.update(lines[i].as_bytes());
}
}
for i in after_start..after_end {
if i < lines.len() {
hasher.update(lines[i].as_bytes());
}
}
let hash = hasher.finalize();
hex_encode(&hash)
}
pub fn verify_context(
lines: &[String],
start: usize,
end: usize,
expected_hash: &str,
) -> Result<(), String> {
let actual_hex = compute_context_hash(lines, start, end);
let actual_short = &actual_hex[..16];
if actual_short == expected_hash {
Ok(())
} else {
Err(format!(
"context mismatch: content around line {} changed. Re-read the file and retry.",
start
))
}
}
pub fn count_recent_backups(filepath: &str, window_secs: u64) -> Result<usize, String> {
let normalized = normalize_path(filepath)?;
let hash = get_path_hash(&normalized);
let dir = PathBuf::from(BACKUP_DIR);
if !dir.exists() {
return Ok(0);
}
let now = SystemTime::now();
let cutoff = now - Duration::from_secs(window_secs);
let count = fs::read_dir(&dir)
.map_err(|e| format!("read backup dir: {e}"))?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&hash))
.filter(|e| {
e.metadata()
.ok()
.and_then(|m| m.modified().ok())
.map(|t| t > cutoff)
.unwrap_or(false)
})
.count();
Ok(count)
}
pub fn handle_backtrack_error(e: std::io::Error, context: &str) -> String {
if e.raw_os_error() == Some(-7) {
format!("CRITICAL: {context} aborted via llmosafe Backtrack Signal (-7). Immune memory triggered: current state matches a previously rolled-back failure pattern.")
} else {
format!("{context}: {e}")
}
}
pub struct SniperLock {
lock_path: PathBuf,
}
#[cfg(unix)]
fn is_process_alive(pid: u32) -> bool {
use std::path::Path;
Path::new(&format!("/proc/{}", pid)).exists()
}
#[cfg(not(unix))]
fn is_process_alive(_pid: u32) -> bool {
true
}
impl SniperLock {
pub fn acquire(filepath: &str) -> Result<Self, String> {
Self::acquire_with_config(filepath, &SniperConfig::from_env())
}
pub fn acquire_with_config(filepath: &str, config: &SniperConfig) -> Result<Self, String> {
let normalized = normalize_path(filepath)?;
let hash = get_path_hash(&normalized);
let dir = PathBuf::from(BACKUP_DIR);
fs::create_dir_all(&dir).map_err(|e| format!("create .sniper: {e}"))?;
let lock_path = dir.join(format!("sniper.{}.lock", hash));
let start = SystemTime::now();
let timeout = config.lock_timeout;
let check_interval = Duration::from_millis(50);
loop {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(mut f) => {
let pid = std::process::id();
if let Err(e) = write!(f, "{}", pid) {
return Err(format!("lock PID write for {filepath}: {e}"));
}
if let Err(e) = f.flush() {
return Err(format!("lock PID flush for {filepath}: {e}"));
}
return Ok(Self { lock_path });
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if start.elapsed().unwrap_or(timeout + Duration::from_secs(1)) > timeout {
let holder_pid = fs::read_to_string(&lock_path)
.ok()
.and_then(|c| c.trim().parse::<u32>().ok());
if let Some(pid) = holder_pid {
if !is_process_alive(pid) {
let _ = fs::remove_file(&lock_path);
continue;
}
}
return Err(format!(
"timeout: another sniper process (PID {}) is editing {} \
(lock held for >{:?}; lock file: {:?})",
holder_pid.map_or("unknown".to_string(), |p| p.to_string()),
filepath,
timeout,
lock_path
));
}
thread::sleep(check_interval);
}
Err(e) => return Err(format!("lock acquire for {filepath}: {e}")),
}
}
}
}
impl Drop for SniperLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.lock_path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io;
use tempfile::TempDir;
#[test]
fn test_compute_context_hash() {
let lines: Vec<String> = vec![
"1".into(),
"2".into(),
"3".into(),
"4".into(),
"5".into(),
"6".into(),
"7".into(),
"8".into(),
"9".into(),
"10".into(),
];
let start = 5;
let end = 6;
let mut hasher = sha2::Sha256::new();
hasher.update(b"2");
hasher.update(b"3");
hasher.update(b"4");
hasher.update(b"7");
hasher.update(b"8");
hasher.update(b"9");
let expected_hash = hex_encode(&hasher.finalize());
let hash = compute_context_hash(&lines, start, end);
assert_eq!(hash, expected_hash);
}
#[test]
fn test_resource_guard_for_testing_controlled_entropy() {
let big_ceiling = usize::MAX / 2;
let guard = ResourceGuard::for_testing(big_ceiling, 800, 30);
assert_eq!(guard.raw_entropy(), 800);
assert_eq!(guard.pressure(), 30);
assert!(guard.check().is_ok());
}
#[test]
fn test_resource_guard_for_testing_low_values() {
let big_ceiling = usize::MAX / 2;
let guard = ResourceGuard::for_testing(big_ceiling, 200, 10);
assert_eq!(guard.raw_entropy(), 200);
assert_eq!(guard.pressure(), 10);
assert!(guard.check().is_ok());
}
#[test]
fn test_handle_backtrack_error_signal_7() {
let err = io::Error::from_raw_os_error(-7);
let result = handle_backtrack_error(err, "TestContext");
assert_eq!(
result,
"CRITICAL: TestContext aborted via llmosafe Backtrack Signal (-7). Immune memory triggered: current state matches a previously rolled-back failure pattern."
);
}
#[test]
fn test_handle_backtrack_error_other() {
let err = io::Error::new(io::ErrorKind::NotFound, "file not found");
let result = handle_backtrack_error(err, "TestContext");
assert_eq!(result, "TestContext: file not found");
}
#[test]
fn test_normalize_path_existing() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("existing.txt");
fs::write(&file_path, "content").unwrap();
let normalized = normalize_path(file_path.to_str().unwrap()).unwrap();
assert_eq!(normalized, file_path.canonicalize().unwrap());
}
#[test]
fn test_normalize_path_new_file() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("new_file.txt");
let normalized = normalize_path(file_path.to_str().unwrap()).unwrap();
assert_eq!(
normalized,
dir.path().canonicalize().unwrap().join("new_file.txt")
);
}
#[test]
fn test_normalize_path_missing_parent() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("missing_dir").join("new_file.txt");
let normalized = normalize_path(file_path.to_str().unwrap()).unwrap();
let expected = dir
.path()
.canonicalize()
.unwrap()
.join("missing_dir")
.join("new_file.txt");
assert_eq!(normalized, expected);
}
#[test]
fn test_normalize_path_invalid_filename() {
let dir = TempDir::new().unwrap();
let invalid_path = dir.path().join("missing_dir").join("..");
let result = normalize_path(invalid_path.to_str().unwrap());
assert!(result.is_err());
}
#[test]
fn test_check_file_size_exceeds_limit() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("large.txt");
fs::write(&file, "x".repeat(100)).unwrap();
let result = check_file_size(file.to_str().unwrap(), 10);
assert!(result.is_err());
assert!(result.unwrap_err().contains("File too large"));
}
#[test]
fn test_check_file_size_unlimited() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("any.txt");
fs::write(&file, "any content").unwrap();
let result = check_file_size(file.to_str().unwrap(), 0);
assert!(result.is_ok());
}
#[test]
fn test_purge_old_backups_by_count() {
use std::thread;
let file = PathBuf::from("test_purge_backup.txt");
fs::write(&file, "v0").unwrap();
let config = SniperConfig {
backup_retention_count: 3,
backup_max_age_days: 0,
..SniperConfig::default()
};
for i in 1..=5 {
fs::write(&file, format!("v{}", i)).unwrap();
create_backup(file.to_str().unwrap()).expect("Backup creation must succeed");
thread::sleep(Duration::from_millis(10));
}
let normalized =
normalize_path(file.to_str().unwrap()).expect("Path normalization must succeed");
let hash = get_path_hash(&normalized);
let backup_dir = PathBuf::from(BACKUP_DIR);
assert!(
backup_dir.exists(),
"Backup dir must exist after creating backups"
);
let before_count: usize = fs::read_dir(&backup_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&hash))
.count();
assert!(
before_count >= 5,
"Must have created at least 5 backups, got {}",
before_count
);
purge_old_backups(file.to_str().unwrap(), &config).expect("Purge must succeed");
let after_count: usize = fs::read_dir(&backup_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&hash))
.count();
assert!(
after_count <= 3,
"After purge with retention=3, expected <= 3 backups, got {}",
after_count
);
let _ = fs::remove_file(&file);
}
#[test]
fn test_hex_encode() {
assert_eq!(hex_encode(b""), "");
assert_eq!(hex_encode(b"hello"), "68656c6c6f");
assert_eq!(hex_encode(b"\x00\xff"), "00ff");
}
#[test]
fn test_normalize_path_with_security() {
let dir = std::env::current_dir().unwrap();
let file = dir.join("test_normalize_path.txt");
let _ = fs::write(&file, "test");
let result = normalize_path("test_normalize_path.txt");
if file.exists() {
let _ = fs::remove_file(&file);
}
assert!(result.is_ok());
let result = normalize_path("../../../etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_pid_config_sleep_duration_zero_defaults() {
let pid = PidConfig::default();
assert_eq!(pid.sleep_duration(0, 0), Duration::from_millis(0));
assert_eq!(pid.sleep_duration(1000, 0), Duration::from_millis(500));
assert_eq!(pid.sleep_duration(0, 100), Duration::from_millis(100));
assert_eq!(pid.sleep_duration(1000, 50), Duration::from_millis(550));
}
#[test]
fn test_pid_config_sleep_duration_with_base_ms() {
let pid = PidConfig {
base_ms: 10,
entropy_scale: 0.5,
pressure_scale: 1.0,
};
assert_eq!(pid.sleep_duration(0, 0), Duration::from_millis(10));
assert_eq!(pid.sleep_duration(1000, 0), Duration::from_millis(510)); assert_eq!(pid.sleep_duration(0, 100), Duration::from_millis(110)); }
#[test]
fn test_pid_config_sleep_duration_large_values() {
let pid = PidConfig {
base_ms: 5,
entropy_scale: 1.0,
pressure_scale: 2.0,
};
assert_eq!(pid.sleep_duration(1000, 100), Duration::from_millis(1205));
}
#[test]
fn test_sigmoid_f64() {
assert!((sigmoid_f64(0.0) - 0.5).abs() < f64::EPSILON);
assert!((sigmoid_f64(1.0) - 0.731).abs() < 0.01);
assert!((sigmoid_f64(-1.0) - 0.269).abs() < 0.01);
assert!((sigmoid_f64(10.0) - 1.0).abs() < 0.0001);
}
fn make_risk_telemetry(score: f64) -> RiskTelemetry {
RiskTelemetry {
combined_risk_bits: 0,
classifier_score: score,
memory_stats: MemoryStats {
available_bytes: 1024,
used_bytes: 512,
pressure: 0,
},
}
}
#[test]
fn test_recommend_from_risk() {
assert_eq!(
recommend_from_risk(&make_risk_telemetry(0.9)),
"Resource pressure high. Consider pausing."
);
assert_eq!(
recommend_from_risk(&make_risk_telemetry(0.5)),
"Moderate load. Proceed with caution."
);
assert_eq!(
recommend_from_risk(&make_risk_telemetry(0.0)),
"Resources nominal."
);
assert_eq!(
recommend_from_risk(&make_risk_telemetry(0.7)),
"Resource pressure high. Consider pausing."
);
assert_eq!(
recommend_from_risk(&make_risk_telemetry(0.4)),
"Moderate load. Proceed with caution."
);
}
#[test]
fn test_risk_telemetry_from_guard() {
use std::usize;
let guard = ResourceGuard::for_testing(usize::MAX / 2, 500, 50);
let telemetry = RiskTelemetry::from_guard(&guard);
assert!(
telemetry.classifier_score > 0.5,
"classifier_score ({}) should be > 0.5 for entropy 500",
telemetry.classifier_score
);
assert!(
telemetry.classifier_score < 0.8,
"classifier_score ({}) should be < 0.8 for entropy 500",
telemetry.classifier_score
);
assert_eq!(
telemetry.combined_risk_bits, 500,
"combined_risk_bits should be 500, got {}",
telemetry.combined_risk_bits
);
assert_eq!(
telemetry.memory_stats.pressure, 50,
"pressure should be 50, got {}",
telemetry.memory_stats.pressure
);
assert!(
telemetry.memory_stats.used_bytes > 0,
"used_bytes should be > 0, got {}",
telemetry.memory_stats.used_bytes
);
assert!(
telemetry.memory_stats.available_bytes > 0,
"available_bytes should be > 0, got {}",
telemetry.memory_stats.available_bytes
);
}
#[test]
fn test_verify_context_match() {
let lines: Vec<String> = vec![
"a".into(), "b".into(), "c".into(),
"X".into(), "Y".into(), "Z".into(),
"d".into(), "e".into(), "f".into(),
];
let expected_hash = compute_context_hash(&lines, 4, 6);
let result = verify_context(&lines, 4, 6, &expected_hash[..16]);
assert!(result.is_ok(), "verify_context should return Ok for matching hash, got {:?}", result);
}
#[test]
fn test_verify_context_mismatch() {
let lines: Vec<String> = vec![
"a".into(), "b".into(), "c".into(),
"X".into(), "Y".into(), "Z".into(),
"d".into(), "e".into(), "f".into(),
];
let wrong_hash = "0000000000000000";
let result = verify_context(&lines, 4, 6, wrong_hash);
assert!(result.is_err(), "verify_context should return Err for wrong hash");
let err_msg = result.unwrap_err();
assert!(
err_msg.contains("context mismatch"),
"error message should contain 'context mismatch', got: {}",
err_msg
);
}
struct CwdGuard {
original: PathBuf,
}
impl CwdGuard {
fn new(original: PathBuf) -> Self {
Self { original }
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.original);
}
}
static CWD_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn test_find_latest_backup_no_dir() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
let result = find_latest_backup("any_file.txt");
assert!(result.is_ok(), "find_latest_backup should return Ok when no .sniper/ dir");
assert!(result.unwrap().is_none(), "should return None when .sniper/ doesn't exist");
}
#[test]
fn test_find_latest_backup_empty() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
fs::create_dir_all(BACKUP_DIR).unwrap();
let test_file = dir.path().join("test_backup.txt");
fs::write(&test_file, "hello").unwrap();
let result = find_latest_backup(test_file.to_str().unwrap());
assert!(result.is_ok(), "find_latest_backup should return Ok when .sniper/ is empty");
assert!(result.unwrap().is_none(), "should return None when no matching backups exist");
}
#[test]
fn test_find_latest_backup_finds_existing() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
fs::create_dir_all(BACKUP_DIR).unwrap();
let test_file = dir.path().join("test_backup_find.txt");
fs::write(&test_file, "content").unwrap();
create_backup(test_file.to_str().unwrap()).expect("create_backup should succeed");
let result = find_latest_backup(test_file.to_str().unwrap());
assert!(result.is_ok(), "find_latest_backup should return Ok");
let found = result.unwrap();
assert!(found.is_some(), "should return Some(path) when backup exists");
let found_path = found.unwrap();
assert!(found_path.exists(), "returned backup path should exist: {:?}", found_path);
}
#[test]
fn test_count_recent_backups_no_dir() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
let result = count_recent_backups("any_file.txt", 3600);
assert!(result.is_ok(), "count_recent_backups should return Ok when no .sniper/ dir");
assert_eq!(result.unwrap(), 0, "should return 0 when .sniper/ doesn't exist");
}
#[test]
fn test_count_recent_backups_with_recent() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
fs::create_dir_all(BACKUP_DIR).unwrap();
let test_file = dir.path().join("test_count.txt");
fs::write(&test_file, "content").unwrap();
create_backup(test_file.to_str().unwrap()).expect("create_backup should succeed");
let count_large = count_recent_backups(test_file.to_str().unwrap(), 3600);
assert!(count_large.is_ok(), "count_recent_backups should succeed");
assert!(
count_large.unwrap() >= 1,
"should find at least 1 backup within 1-hour window"
);
let count_zero = count_recent_backups(test_file.to_str().unwrap(), 0);
assert!(count_zero.is_ok(), "count_recent_backups should succeed");
assert_eq!(
count_zero.unwrap(), 0,
"should return 0 with 0-second window (no backups strictly after now)"
);
}
#[test]
fn test_create_backup_nonexistent_source() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
let nonexistent = dir.path().join("does_not_exist.txt");
let result = create_backup(nonexistent.to_str().unwrap());
assert!(result.is_ok(), "create_backup should return Ok for nonexistent source, got {:?}", result);
let backup_path_str = result.unwrap();
let backup_path = std::path::Path::new(&backup_path_str);
assert!(backup_path.exists(), "backup file should exist at {}", backup_path_str);
let metadata = fs::metadata(backup_path).expect("should be able to read backup metadata");
assert_eq!(metadata.len(), 0, "backup file should be empty (length 0), got {}", metadata.len());
}
#[test]
fn test_purge_old_backups_no_policy() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
let file = PathBuf::from("test_no_policy.txt");
fs::write(&file, "content").unwrap();
let config = SniperConfig {
backup_retention_count: 0,
backup_max_age_days: 0,
..SniperConfig::default()
};
let result = purge_old_backups(file.to_str().unwrap(), &config);
assert!(
result.is_ok(),
"purge_old_backups should return Ok when no policy configured, got {:?}",
result
);
}
#[test]
fn test_purge_old_backups_dir_not_exist() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
let file = PathBuf::from("test_dir_not_exist.txt");
fs::write(&file, "content").unwrap();
let config = SniperConfig {
backup_retention_count: 5,
..SniperConfig::default()
};
let result = purge_old_backups(file.to_str().unwrap(), &config);
assert!(
result.is_ok(),
"purge_old_backups should return Ok when .sniper/ doesn't exist, got {:?}",
result
);
}
#[test]
fn test_purge_old_backups_by_age() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
let file = PathBuf::from("test_age_purge.txt");
fs::write(&file, "content").unwrap();
fs::create_dir_all(BACKUP_DIR).unwrap();
let normalized = normalize_path(file.to_str().unwrap())
.expect("normalize_path should succeed");
let hash = get_path_hash(&normalized);
for i in 1..=3 {
let backup_name = format!("{}.test_age_purge.txt.{}", hash, i * 1000);
let backup_path = PathBuf::from(BACKUP_DIR).join(&backup_name);
fs::File::create(&backup_path)
.unwrap_or_else(|e| panic!("create backup {}: {}", backup_name, e));
}
let config = SniperConfig {
backup_max_age_days: 36500,
backup_retention_count: 0,
..SniperConfig::default()
};
let result = purge_old_backups(file.to_str().unwrap(), &config);
let remaining: Vec<_> = fs::read_dir(BACKUP_DIR)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&hash))
.collect();
assert!(
result.is_ok(),
"purge_old_backups should return Ok with long max age, got {:?}",
result
);
assert_eq!(
remaining.len(),
3,
"all 3 backups should remain when max age is 100 years, got {}",
remaining.len()
);
}
#[test]
fn test_write_atomic_with_dal_maximum() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
let file = PathBuf::from("test_dal_max.txt");
fs::write(&file, "original\n").unwrap();
let guard = ResourceGuard::for_testing(usize::MAX / 2, 0, 0);
let result = write_atomic_with_dal(
file.to_str().unwrap(),
&["replaced"],
&guard,
DalLevel::Maximum,
);
let content = fs::read_to_string(&file).unwrap();
assert!(
result.is_ok(),
"write_atomic_with_dal Maximum should succeed, got {:?}",
result
);
assert_eq!(
content, "replaced\n",
"file content should be 'replaced\\n', got {:?}",
content
);
}
#[test]
fn test_write_atomic_with_dal_baseline() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
let file = PathBuf::from("test_dal_baseline.txt");
fs::write(&file, "original\n").unwrap();
let guard = ResourceGuard::for_testing(usize::MAX / 2, 0, 0);
let result = write_atomic_with_dal(
file.to_str().unwrap(),
&["baseline"],
&guard,
DalLevel::Baseline,
);
let content = fs::read_to_string(&file).unwrap();
assert!(
result.is_ok(),
"write_atomic_with_dal Baseline should succeed, got {:?}",
result
);
assert_eq!(
content, "baseline\n",
"file content should be 'baseline\\n', got {:?}",
content
);
}
#[cfg(unix)]
#[test]
fn test_lock_stale_pid_cleanup() {
let _serial = CWD_MUTEX.lock().unwrap();
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = CwdGuard::new(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
let file = PathBuf::from("test_stale_lock.txt");
fs::write(&file, "content").unwrap();
fs::create_dir_all(BACKUP_DIR).unwrap();
let normalized = normalize_path(file.to_str().unwrap())
.expect("normalize_path should succeed");
let hash = get_path_hash(&normalized);
let lock_path = PathBuf::from(BACKUP_DIR).join(format!("sniper.{}.lock", hash));
fs::write(&lock_path, "99999").unwrap();
assert!(
lock_path.exists(),
"lock file should exist before acquire attempt"
);
let config = SniperConfig {
lock_timeout: Duration::from_millis(1),
..SniperConfig::default()
};
let result = SniperLock::acquire_with_config(file.to_str().unwrap(), &config);
assert!(
result.is_ok(),
"SniperLock::acquire_with_config should succeed after stale lock cleanup"
);
let lock = result.unwrap();
let current_pid = std::process::id().to_string();
let lock_content = fs::read_to_string(&lock.lock_path).unwrap();
assert_eq!(
lock_content.trim(),
current_pid,
"lock file should contain current PID after re-acquisition"
);
}
}