use std::fs; use std::path::{Component, Path, PathBuf}; use chrono::{DateTime, Utc}; use crate::types::VersionEntry; fn safe_path_component<'a>(value: &'a str, label: &str) -> Result<&'a str, String> { let mut components = Path::new(value).components(); if value.is_empty() || !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() { return Err(format!("Invalid {label}")); } Ok(value) } /// Directory: .helixnotes/history// fn history_dir(vault_path: &str, note_id: &str) -> Result { Ok(Path::new(vault_path) .join(".helixnotes") .join("history") .join(safe_path_component(note_id, "note ID")?)) } /// Save a version snapshot if enough time has passed since the last one. /// Minimum interval: 5 minutes between snapshots. pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) { let Ok(dir) = history_dir(vault_path, note_id) else { log::warn!("Skipping history snapshot with an invalid note ID"); return; }; // Check if we should create a snapshot (5 min cooldown) if let Ok(entries) = fs::read_dir(&dir) { let mut files: Vec = entries .filter_map(|e| e.ok()) .filter_map(|e| e.file_name().to_str().map(|s| s.to_string())) .filter(|n| n.ends_with(".md")) .collect(); files.sort(); if let Some(last) = files.last() { if let Some(ts) = last.strip_suffix(".md") { // Parse timestamp: 2026-02-08T18-30-00.md if let Some(t_pos) = ts.find('T') { let date_part = &ts[..t_pos]; let time_part = &ts[t_pos + 1..]; let time_colons = time_part.replace('-', ":"); let iso = format!("{}T{}Z", date_part, time_colons); if let Ok(last_time) = iso.parse::>() { let elapsed = Utc::now() - last_time; if elapsed.num_minutes() < 5 { return; // Too soon, skip } } } } } } // Create snapshot if let Err(e) = fs::create_dir_all(&dir) { eprintln!("Failed to create history dir: {}", e); return; } let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string(); let filename = format!("{}.md", timestamp); let path = dir.join(&filename); if let Err(e) = fs::write(&path, raw_content) { eprintln!("Failed to write version snapshot: {}", e); return; } // Prune old versions if max_versions > 0 { let _ = prune_versions(&dir, max_versions); } } /// Force-create a version snapshot, bypassing the cooldown. pub fn force_snapshot( vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32, ) -> Result<(), String> { let dir = history_dir(vault_path, note_id)?; fs::create_dir_all(&dir).map_err(|error| error.to_string())?; let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string(); let filename = format!("{}.md", timestamp); let path = dir.join(&filename); fs::write(&path, raw_content).map_err(|error| error.to_string())?; if max_versions > 0 { prune_versions(&dir, max_versions)?; } Ok(()) } /// List all version snapshots for a note, newest first. pub fn list_versions(vault_path: &str, note_id: &str) -> Result, String> { let dir = history_dir(vault_path, note_id)?; if !dir.exists() { return Ok(Vec::new()); } let mut entries: Vec = Vec::new(); for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if path.extension().is_some_and(|ext| ext == "md") { let filename = path .file_stem() .unwrap_or_default() .to_string_lossy() .to_string(); let meta = fs::metadata(&path).map_err(|e| e.to_string())?; // Parse timestamp from filename let timestamp = if let Some(t_pos) = filename.find('T') { let date_part = &filename[..t_pos]; let time_part = &filename[t_pos + 1..]; let time_colons = time_part.replace('-', ":"); format!("{}T{}Z", date_part, time_colons) } else { filename.clone() }; entries.push(VersionEntry { timestamp, size: meta.len(), }); } } // Newest first entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); Ok(entries) } /// Get the raw content of a specific version. pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result { safe_path_component(timestamp, "version timestamp")?; // Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md let filename = if let Some(t_pos) = timestamp.find('T') { let date_part = ×tamp[..t_pos]; let time_part = timestamp[t_pos + 1..].trim_end_matches('Z'); let time_dashes = time_part.replace(':', "-"); format!("{date_part}T{time_dashes}.md") } else { format!("{}.md", timestamp) }; let path = history_dir(vault_path, note_id)?.join(&filename); fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e)) } /// Remove old versions keeping only the newest `max` entries. fn prune_versions(dir: &Path, max: u32) -> Result<(), String> { let mut files: Vec = fs::read_dir(dir) .map_err(|e| e.to_string())? .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|ext| ext == "md")) .collect(); // Sort by name (timestamps sort lexicographically) - newest last files.sort(); if files.len() as u32 > max { let to_remove = files.len() as u32 - max; for path in files.iter().take(to_remove as usize) { let _ = fs::remove_file(path); } } Ok(()) } #[cfg(test)] mod tests { use super::{get_version, list_versions}; use std::fs; use uuid::Uuid; #[test] fn rejects_history_path_traversal() { let vault = std::env::temp_dir().join(format!("helixnotes-history-test-{}", Uuid::new_v4())); let metadata = vault.join(".helixnotes"); let escaped_history = metadata.join("escaped"); fs::create_dir_all(&escaped_history).unwrap(); fs::write(escaped_history.join("2026-01-01T00-00-00.md"), "escaped").unwrap(); fs::create_dir_all(metadata.join("history").join("safe")).unwrap(); fs::write(metadata.join("secret.md"), "secret").unwrap(); assert!(list_versions(&vault.to_string_lossy(), "../escaped").is_err()); assert!(get_version(&vault.to_string_lossy(), "safe", "../../secret").is_err()); fs::remove_dir_all(vault).unwrap(); } }