diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad55ffa..65a133d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,119 @@ use serde::{Deserialize, Serialize}; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use walkdir::WalkDir; use regex::Regex; use chrono::Local; +/* ── Atomic File Write ───────────────────────────────────── + * 1. Write to a `.tmp` sibling in the same directory + * 2. fsync the file descriptor (data hits disk) + * 3. Atomic rename `.tmp` → target (POSIX guarantees) + * 4. fsync the parent directory (metadata durability) + * + * If any step fails the original file is untouched and + * the `.tmp` file is cleaned up. + * ────────────────────────────────────────────────────────── */ + +fn atomic_write(path: &Path, content: &str) -> Result<(), String> { + atomic_write_bytes(path, content.as_bytes()) +} + +fn atomic_write_bytes(path: &Path, data: &[u8]) -> Result<(), String> { + let parent = path.parent() + .ok_or_else(|| "Cannot determine parent directory".to_string())?; + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory: {}", e))?; + + // Use ~tmp suffix (appended, not replacing extension) to avoid + // colliding with real files that share the same stem + let mut tmp_name = path.as_os_str().to_os_string(); + tmp_name.push("~tmp"); + let tmp_path = PathBuf::from(tmp_name); + + // 1 — Write to .tmp + let result = (|| -> Result<(), String> { + let mut file = fs::File::create(&tmp_path) + .map_err(|e| format!("Failed to create temp file: {}", e))?; + file.write_all(data) + .map_err(|e| format!("Failed to write temp file: {}", e))?; + + // 2 — fsync: flush data to disk + file.sync_all() + .map_err(|e| format!("Failed to sync file: {}", e))?; + Ok(()) + })(); + + if let Err(e) = result { + let _ = fs::remove_file(&tmp_path); // Clean up on failure + return Err(e); + } + + // 3 — Atomic rename + if let Err(e) = fs::rename(&tmp_path, path) { + let _ = fs::remove_file(&tmp_path); + return Err(format!("Failed to finalize write: {}", e)); + } + + // 4 — fsync parent directory for metadata durability + if let Ok(dir) = fs::File::open(parent) { + let _ = dir.sync_all(); + } + + Ok(()) +} + +/// Sanitize a name used to construct filenames (workspace names, canvas names, etc.) +/// Rejects any path component separators or traversal sequences. +fn safe_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Name cannot be empty".to_string()); + } + if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") || trimmed.contains('\0') { + return Err("Name contains invalid characters".to_string()); + } + Ok(trimmed.to_string()) +} + +/* ── Static Regexes ──────────────────────────────────────── + * Compiled once, reused across calls. + * ────────────────────────────────────────────────────────── */ + +static PREVIEW_WIKILINK_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]").unwrap() +}); + +static PREVIEW_FMT_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"[*_~`]").unwrap() +}); + +static DAILY_NOTE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").unwrap() +}); + +static TASK_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(\s*)- \[([ x/])\] (.+)$").unwrap() +}); + +static TASK_MARKER_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"- \[[ x/]\]").unwrap() +}); + +static EXPORT_WIKILINK_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap() +}); + +static FLASHCARD_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\?\?\s*(.+?)\s*::\s*(.+?)\s*\?\?").unwrap() +}); + +static FRONTMATTER_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)^---\n(.+?)\n---").unwrap() +}); + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct NoteEntry { pub path: String, @@ -131,13 +239,7 @@ fn read_note(vault_path: String, relative_path: String) -> Result Result<(), String> { let full_path = safe_vault_path(&vault_path, &relative_path)?; - - // Ensure parent directory exists - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?; - } - - fs::write(&full_path, content).map_err(|e| format!("Failed to write note: {}", e)) + atomic_write(&full_path, &content) } #[tauri::command] @@ -221,7 +323,7 @@ fn get_or_create_daily(vault_path: String) -> Result { if !daily_path.exists() { let content = format!("# {}\n\n", today); - fs::write(&daily_path, content).map_err(|e| format!("Failed to create daily note: {}", e))?; + atomic_write(&daily_path, &content)?; } Ok(relative_path) @@ -246,10 +348,7 @@ fn get_vault_path() -> Result, String> { #[tauri::command] fn set_vault_path(path: String) -> Result<(), String> { let config_path = dirs_config_path(); - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - fs::write(&config_path, &path).map_err(|e| e.to_string()) + atomic_write(&config_path, &path) } fn dirs_config_path() -> PathBuf { @@ -336,9 +435,8 @@ fn search_vault(vault_path: String, query: String) -> Result, #[tauri::command] fn rename_note(vault_path: String, old_path: String, new_path: String) -> Result<(), String> { - let vault = Path::new(&vault_path); - let old_full = vault.join(&old_path); - let new_full = vault.join(&new_path); + let old_full = safe_vault_path(&vault_path, &old_path)?; + let new_full = safe_vault_path(&vault_path, &new_path)?; if !old_full.exists() { return Err("Source note does not exist".to_string()); @@ -360,24 +458,22 @@ fn update_wikilinks(vault_path: String, old_name: String, new_name: String) -> R let vault = Path::new(&vault_path); let mut updated_count = 0; + // Compile regexes once before the loop + let pattern = format!(r"\[\[{}\]\]", regex::escape(&old_name)); + let pattern_with_alias = format!(r"\[\[{}\|", regex::escape(&old_name)); + let re1 = Regex::new(&pattern).map_err(|e| format!("Invalid regex: {}", e))?; + let re2 = Regex::new(&pattern_with_alias).map_err(|e| format!("Invalid regex: {}", e))?; + for entry in WalkDir::new(vault) .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.path().extension().map_or(false, |ext| ext == "md")) { if let Ok(content) = fs::read_to_string(entry.path()) { - // Replace [[old_name]] and [[old_name|display]] patterns - let pattern = format!(r"\[\[{}\]\]", regex::escape(&old_name)); - let pattern_with_alias = format!(r"\[\[{}\|", regex::escape(&old_name)); - - let re1 = Regex::new(&pattern).unwrap(); - let re2 = Regex::new(&pattern_with_alias).unwrap(); - if re1.is_match(&content) || re2.is_match(&content) { let new_content = re1.replace_all(&content, format!("[[{}]]", new_name)); let new_content = re2.replace_all(&new_content, format!("[[{}|", new_name)); - fs::write(entry.path(), new_content.as_ref()) - .map_err(|e| format!("Failed to update links: {}", e))?; + atomic_write(entry.path(), &new_content)?; updated_count += 1; } } @@ -450,7 +546,7 @@ fn list_tags(vault_path: String) -> Result, String> { #[tauri::command] fn read_note_preview(vault_path: String, note_path: String, max_chars: Option) -> Result { - let full = Path::new(&vault_path).join(¬e_path); + let full = safe_vault_path(&vault_path, ¬e_path)?; let content = fs::read_to_string(&full).map_err(|e| format!("Read failed: {}", e))?; let limit = max_chars.unwrap_or(200); @@ -458,17 +554,11 @@ fn read_note_preview(vault_path: String, note_path: String, max_chars: Option>() .join(" "); - let re_wikilink = Regex::new(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]").unwrap(); - let cleaned = re_wikilink.replace_all(&cleaned, "$1").to_string(); - let re_fmt = Regex::new(r"[*_~`]").unwrap(); - let cleaned = re_fmt.replace_all(&cleaned, "").to_string(); + let cleaned = PREVIEW_WIKILINK_RE.replace_all(&cleaned, "$1").to_string(); + let cleaned = PREVIEW_FMT_RE.replace_all(&cleaned, "").to_string(); let cleaned = cleaned.trim().to_string(); if cleaned.len() > limit { @@ -506,11 +596,8 @@ fn add_vault(vault_path: String) -> Result<(), String> { vaults.insert(0, vault_path); vaults.truncate(10); // Keep last 10 - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } let json = serde_json::to_string_pretty(&vaults).map_err(|e| e.to_string())?; - fs::write(&config_path, json).map_err(|e| e.to_string()) + atomic_write(&config_path, &json) } /* ── Templates ───────────────────────────────────────────── */ @@ -537,7 +624,7 @@ fn list_templates(vault_path: String) -> Result, String> { .unwrap_or_default() .to_string_lossy() .to_string(); - let rel = format!("_templates/{}", path.file_name().unwrap().to_string_lossy()); + let rel = format!("_templates/{}", path.file_name().unwrap_or_default().to_string_lossy()); templates.push(TemplateInfo { name, path: rel }); } } @@ -551,8 +638,7 @@ fn create_from_template( template_path: String, note_name: String, ) -> Result { - let vault = Path::new(&vault_path); - let template_full = vault.join(&template_path); + let template_full = safe_vault_path(&vault_path, &template_path)?; let template_content = fs::read_to_string(&template_full) .map_err(|e| format!("Failed to read template: {}", e))?; @@ -562,11 +648,8 @@ fn create_from_template( .replace("{{date}}", &today); let note_path = format!("{}.md", note_name); - let full_path = vault.join(¬e_path); - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - fs::write(&full_path, content).map_err(|e| format!("Failed to create note: {}", e))?; + let full_path = safe_vault_path(&vault_path, ¬e_path)?; + atomic_write(&full_path, &content)?; Ok(note_path) } @@ -588,14 +671,14 @@ fn set_favorites(vault_path: String, favorites: Vec) -> Result<(), Strin let dir = Path::new(&vault_path).join(".graph-notes"); fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let json = serde_json::to_string_pretty(&favorites).map_err(|e| e.to_string())?; - fs::write(dir.join("favorites.json"), json).map_err(|e| e.to_string()) + atomic_write(&dir.join("favorites.json"), &json) } /* ── Frontmatter ────────────────────────────────────────── */ #[tauri::command] fn parse_frontmatter(vault_path: String, note_path: String) -> Result { - let full = Path::new(&vault_path).join(¬e_path); + let full = safe_vault_path(&vault_path, ¬e_path)?; let content = fs::read_to_string(&full).map_err(|e| e.to_string())?; if !content.starts_with("---\n") { @@ -627,7 +710,7 @@ fn write_frontmatter( note_path: String, frontmatter: serde_json::Map, ) -> Result<(), String> { - let full = Path::new(&vault_path).join(¬e_path); + let full = safe_vault_path(&vault_path, ¬e_path)?; let content = fs::read_to_string(&full).map_err(|e| e.to_string())?; // Strip existing frontmatter @@ -658,7 +741,7 @@ fn write_frontmatter( format!("{}{}", yaml, body) }; - fs::write(&full, new_content).map_err(|e| e.to_string()) + atomic_write(&full, &new_content) } /* ── Attachments ────────────────────────────────────────── */ @@ -669,11 +752,13 @@ fn save_attachment( file_name: String, data: Vec, ) -> Result { + // Validate filename — reject path separators and traversal + let sanitized = safe_name(&file_name)?; let attach_dir = Path::new(&vault_path).join("_attachments"); fs::create_dir_all(&attach_dir).map_err(|e| e.to_string())?; // Deduplicate filename - let mut target = attach_dir.join(&file_name); + let mut target = attach_dir.join(&sanitized); let stem = target.file_stem().unwrap_or_default().to_string_lossy().to_string(); let ext = target.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); let mut counter = 1; @@ -682,8 +767,9 @@ fn save_attachment( counter += 1; } - fs::write(&target, &data).map_err(|e| e.to_string())?; - let rel = format!("_attachments/{}", target.file_name().unwrap().to_string_lossy()); + atomic_write_bytes(&target, &data)?; + let final_name = target.file_name().unwrap_or_default().to_string_lossy(); + let rel = format!("_attachments/{}", final_name); Ok(rel) } @@ -714,13 +800,12 @@ fn list_daily_notes(vault_path: String) -> Result, String> { return Ok(vec![]); } - let re = Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").unwrap(); let mut dates: Vec = Vec::new(); for entry in fs::read_dir(&daily_dir).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let name = entry.file_name().to_string_lossy().to_string(); - if re.is_match(&name) { + if DAILY_NOTE_RE.is_match(&name) { dates.push(name.replace(".md", "")); } } @@ -742,16 +827,14 @@ fn get_theme() -> Result { #[tauri::command] fn set_theme(theme: String) -> Result<(), String> { - let dir = dirs_config_dir(); - fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - fs::write(dir.join("theme"), &theme).map_err(|e| e.to_string()) + atomic_write(&dirs_config_dir().join("theme"), &theme) } /* ── Export ──────────────────────────────────────────────── */ #[tauri::command] fn export_note_html(vault_path: String, note_path: String) -> Result { - let full = Path::new(&vault_path).join(¬e_path); + let full = safe_vault_path(&vault_path, ¬e_path)?; let content = fs::read_to_string(&full).map_err(|e| e.to_string())?; let title = Path::new(¬e_path) .file_stem() @@ -782,8 +865,7 @@ fn export_note_html(vault_path: String, note_path: String) -> Result{}", target, label) @@ -824,7 +906,6 @@ pub struct TaskItem { #[tauri::command] fn list_tasks(vault_path: String) -> Result, String> { let vault = Path::new(&vault_path); - let re = Regex::new(r"^(\s*)- \[([ x/])\] (.+)$").unwrap(); let mut tasks: Vec = Vec::new(); for entry in WalkDir::new(vault) @@ -840,7 +921,7 @@ fn list_tasks(vault_path: String) -> Result, String> { if let Ok(content) = fs::read_to_string(path) { for (i, line) in content.lines().enumerate() { - if let Some(caps) = re.captures(line) { + if let Some(caps) = TASK_RE.captures(line) { let marker = &caps[2]; let state = match marker { "x" => "done", @@ -863,7 +944,7 @@ fn list_tasks(vault_path: String) -> Result, String> { #[tauri::command] fn toggle_task(vault_path: String, note_path: String, line_number: usize, new_state: String) -> Result<(), String> { - let full = Path::new(&vault_path).join(¬e_path); + let full = safe_vault_path(&vault_path, ¬e_path)?; let content = fs::read_to_string(&full).map_err(|e| e.to_string())?; let mut lines: Vec = content.lines().map(|s| s.to_string()).collect(); @@ -877,9 +958,8 @@ fn toggle_task(vault_path: String, note_path: String, line_number: usize, new_st _ => " ", }; - let re = Regex::new(r"- \[[ x/]\]").unwrap(); let line = &lines[line_number - 1]; - if let Some(m) = re.find(line) { + if let Some(m) = TASK_MARKER_RE.find(line) { let mut new_line = String::new(); new_line.push_str(&line[..m.start()]); new_line.push_str(&format!("- [{}]", marker)); @@ -887,7 +967,7 @@ fn toggle_task(vault_path: String, note_path: String, line_number: usize, new_st lines[line_number - 1] = new_line; } - fs::write(&full, lines.join("\n") + "\n").map_err(|e| e.to_string()) + atomic_write(&full, &(lines.join("\n") + "\n")) } /* ── Snapshots (Version History) ────────────────────────── */ @@ -901,23 +981,24 @@ pub struct SnapshotInfo { #[tauri::command] fn save_snapshot(vault_path: String, note_path: String) -> Result { - let full = Path::new(&vault_path).join(¬e_path); + let full = safe_vault_path(&vault_path, ¬e_path)?; let content = fs::read_to_string(&full).map_err(|e| e.to_string())?; - let safe_name = note_path.replace('/', "__").replace(".md", ""); - let history_dir = Path::new(&vault_path).join(".graph-notes").join("history").join(&safe_name); + let sanitized_name = note_path.replace('/', "__").replace(".md", ""); + let history_dir = Path::new(&vault_path).join(".graph-notes").join("history").join(&sanitized_name); fs::create_dir_all(&history_dir).map_err(|e| e.to_string())?; let ts = Local::now().format("%Y%m%d_%H%M%S").to_string(); let snap_name = format!("{}.md", ts); + // Snapshots are write-once, never overwritten — direct write is safe fs::write(history_dir.join(&snap_name), &content).map_err(|e| e.to_string())?; Ok(snap_name) } #[tauri::command] fn list_snapshots(vault_path: String, note_path: String) -> Result, String> { - let safe_name = note_path.replace('/', "__").replace(".md", ""); - let history_dir = Path::new(&vault_path).join(".graph-notes").join("history").join(&safe_name); + let sanitized_name = note_path.replace('/', "__").replace(".md", ""); + let history_dir = Path::new(&vault_path).join(".graph-notes").join("history").join(&sanitized_name); if !history_dir.exists() { return Ok(vec![]); @@ -943,11 +1024,11 @@ fn list_snapshots(vault_path: String, note_path: String) -> Result Result { - let safe_name = note_path.replace('/', "__").replace(".md", ""); + let sanitized_name = note_path.replace('/', "__").replace(".md", ""); let snap_path = Path::new(&vault_path) .join(".graph-notes") .join("history") - .join(&safe_name) + .join(&sanitized_name) .join(&snapshot_name); fs::read_to_string(&snap_path).map_err(|e| e.to_string()) } @@ -986,7 +1067,7 @@ fn search_replace_vault( if count > 0 { if !dry_run { let new_content = content.replace(&search, &replace); - let _ = fs::write(path, new_content); + atomic_write(path, &new_content)?; } results.push(ReplaceResult { path: rel, @@ -1035,7 +1116,7 @@ fn set_writing_goal(vault_path: String, note_path: String, goal: u32) -> Result< } let json = serde_json::to_string_pretty(&goals).map_err(|e| e.to_string())?; - fs::write(&goals_path, json).map_err(|e| e.to_string()) + atomic_write(&goals_path, &json) } /* ── Note Refactoring ───────────────────────────────────── */ @@ -1047,20 +1128,19 @@ fn extract_to_note( selected_text: String, new_note_name: String, ) -> Result { - let vault = Path::new(&vault_path); - let new_path = vault.join(format!("{}.md", &new_note_name)); + let new_path = safe_vault_path(&vault_path, &format!("{}.md", &new_note_name))?; if new_path.exists() { return Err(format!("Note '{}' already exists", new_note_name)); } // Create new note with extracted text - fs::write(&new_path, &selected_text).map_err(|e| e.to_string())?; + atomic_write(&new_path, &selected_text)?; // Replace selected text with wikilink in source - let source_full = vault.join(&source_path); + let source_full = safe_vault_path(&vault_path, &source_path)?; let content = fs::read_to_string(&source_full).map_err(|e| e.to_string())?; let new_content = content.replacen(&selected_text, &format!("[[{}]]", new_note_name), 1); - fs::write(&source_full, new_content).map_err(|e| e.to_string())?; + atomic_write(&source_full, &new_content)?; Ok(format!("{}.md", new_note_name)) } @@ -1072,15 +1152,15 @@ fn merge_notes( target_path: String, ) -> Result<(), String> { let vault = Path::new(&vault_path); - let source_full = vault.join(&source_path); - let target_full = vault.join(&target_path); + let source_full = safe_vault_path(&vault_path, &source_path)?; + let target_full = safe_vault_path(&vault_path, &target_path)?; let source_content = fs::read_to_string(&source_full).map_err(|e| e.to_string())?; let target_content = fs::read_to_string(&target_full).map_err(|e| e.to_string())?; let source_name = source_path.replace(".md", ""); let merged = format!("{}\n\n---\n\n## Merged from {}\n\n{}", target_content.trim_end(), source_name, source_content); - fs::write(&target_full, merged).map_err(|e| e.to_string())?; + atomic_write(&target_full, &merged)?; // Delete source fs::remove_file(&source_full).map_err(|e| e.to_string())?; @@ -1100,7 +1180,7 @@ fn merge_notes( &format!("[[{}]]", target_name), ); if updated != content { - let _ = fs::write(path, updated); + atomic_write(path, &updated)?; } } } @@ -1124,7 +1204,7 @@ fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] { #[tauri::command] fn encrypt_note(vault_path: String, note_path: String, password: String) -> Result<(), String> { - let full = Path::new(&vault_path).join(¬e_path); + let full = safe_vault_path(&vault_path, ¬e_path)?; let content = fs::read_to_string(&full).map_err(|e| e.to_string())?; let salt: [u8; 16] = rand::random(); @@ -1145,12 +1225,12 @@ fn encrypt_note(vault_path: String, note_path: String, password: String) -> Resu B64.encode(nonce_bytes), B64.encode(&ciphertext), ); - fs::write(&full, encoded).map_err(|e| e.to_string()) + atomic_write(&full, &encoded) } #[tauri::command] fn decrypt_note(vault_path: String, note_path: String, password: String) -> Result { - let full = Path::new(&vault_path).join(¬e_path); + let full = safe_vault_path(&vault_path, ¬e_path)?; let content = fs::read_to_string(&full).map_err(|e| e.to_string())?; if !content.starts_with("GRAPHNOTES_ENC:v1:") { @@ -1179,7 +1259,7 @@ fn decrypt_note(vault_path: String, note_path: String, password: String) -> Resu #[tauri::command] fn is_encrypted(vault_path: String, note_path: String) -> Result { - let full = Path::new(&vault_path).join(¬e_path); + let full = safe_vault_path(&vault_path, ¬e_path)?; let content = fs::read_to_string(&full).map_err(|e| e.to_string())?; Ok(content.starts_with("GRAPHNOTES_ENC:v1:")) } @@ -1200,7 +1280,6 @@ pub struct Flashcard { #[tauri::command] fn list_flashcards(vault_path: String) -> Result, String> { let vault = Path::new(&vault_path); - let re = Regex::new(r"\?\?\s*(.+?)\s*::\s*(.+?)\s*\?\?").unwrap(); let mut cards: Vec = Vec::new(); // Load schedule data @@ -1223,7 +1302,7 @@ fn list_flashcards(vault_path: String) -> Result, String> { if let Ok(content) = fs::read_to_string(path) { for (i, line) in content.lines().enumerate() { - for caps in re.captures_iter(line) { + for caps in FLASHCARD_RE.captures_iter(line) { let q = caps[1].trim().to_string(); let a = caps[2].trim().to_string(); let card_id = format!("{}:{}", rel, i + 1); @@ -1293,7 +1372,7 @@ fn update_card_schedule( obj.insert("due".into(), serde_json::json!(due.format("%Y-%m-%d").to_string())); let json = serde_json::to_string_pretty(&srs).map_err(|e| e.to_string())?; - fs::write(&srs_path, json).map_err(|e| e.to_string()) + atomic_write(&srs_path, &json) } /* ── Fold State ─────────────────────────────────────────── */ @@ -1313,7 +1392,7 @@ fn save_fold_state(vault_path: String, note_path: String, folds: Vec) -> data.insert(note_path, serde_json::json!(folds)); let json = serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?; - fs::write(&folds_path, json).map_err(|e| e.to_string()) + atomic_write(&folds_path, &json) } #[tauri::command] @@ -1344,23 +1423,23 @@ fn get_custom_css() -> Result { #[tauri::command] fn set_custom_css(css: String) -> Result<(), String> { - let config_dir = dirs_config_dir(); - fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?; - fs::write(config_dir.join("custom.css"), css).map_err(|e| e.to_string()) + atomic_write(&dirs_config_dir().join("custom.css"), &css) } /* ── Workspace Layouts ──────────────────────────────────── */ #[tauri::command] fn save_workspace(vault_path: String, name: String, state: String) -> Result<(), String> { + let sanitized = safe_name(&name)?; let dir = Path::new(&vault_path).join(".graph-notes").join("workspaces"); fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - fs::write(dir.join(format!("{}.json", name)), state).map_err(|e| e.to_string()) + atomic_write(&dir.join(format!("{}.json", sanitized)), &state) } #[tauri::command] fn load_workspace(vault_path: String, name: String) -> Result { - let path = Path::new(&vault_path).join(".graph-notes").join("workspaces").join(format!("{}.json", name)); + let sanitized = safe_name(&name)?; + let path = Path::new(&vault_path).join(".graph-notes").join("workspaces").join(format!("{}.json", sanitized)); fs::read_to_string(&path).map_err(|e| e.to_string()) } @@ -1384,7 +1463,7 @@ fn list_workspaces(vault_path: String) -> Result, String> { fn save_tabs(vault_path: String, tabs: String) -> Result<(), String> { let dir = Path::new(&vault_path).join(".graph-notes"); fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - fs::write(dir.join("tabs.json"), tabs).map_err(|e| e.to_string()) + atomic_write(&dir.join("tabs.json"), &tabs) } #[tauri::command] @@ -1398,14 +1477,16 @@ fn load_tabs(vault_path: String) -> Result { #[tauri::command] fn save_canvas(vault_path: String, name: String, data: String) -> Result<(), String> { + let sanitized = safe_name(&name)?; let dir = Path::new(&vault_path).join(".graph-notes").join("canvases"); fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - fs::write(dir.join(format!("{}.json", name)), data).map_err(|e| e.to_string()) + atomic_write(&dir.join(format!("{}.json", sanitized)), &data) } #[tauri::command] fn load_canvas(vault_path: String, name: String) -> Result { - let path = Path::new(&vault_path).join(".graph-notes").join("canvases").join(format!("{}.json", name)); + let sanitized = safe_name(&name)?; + let path = Path::new(&vault_path).join(".graph-notes").join("canvases").join(format!("{}.json", sanitized)); if !path.exists() { return Ok("{}".to_string()); } fs::read_to_string(&path).map_err(|e| e.to_string()) } @@ -1437,7 +1518,6 @@ pub struct FrontmatterRow { fn query_frontmatter(vault_path: String) -> Result, String> { let vault = Path::new(&vault_path); let mut rows: Vec = Vec::new(); - let fm_re = Regex::new(r"(?s)^---\n(.+?)\n---").unwrap(); for entry in WalkDir::new(vault) .into_iter() @@ -1449,7 +1529,7 @@ fn query_frontmatter(vault_path: String) -> Result, String> if rel.starts_with(".") { continue; } if let Ok(content) = fs::read_to_string(path) { - if let Some(caps) = fm_re.captures(&content) { + if let Some(caps) = FRONTMATTER_RE.captures(&content) { let yaml_str = &caps[1]; let mut fields = serde_json::Map::new(); for line in yaml_str.lines() { @@ -1523,7 +1603,7 @@ pub struct DataviewResult { #[tauri::command] fn run_dataview_query(vault_path: String, query: String) -> Result { let vault = Path::new(&vault_path); - let fm_re = Regex::new(r"(?s)^---\n(.+?)\n---").unwrap(); + // Parse query: TABLE field1, field2 [FROM ""] [WHERE cond] [SORT field [ASC|DESC]] let query_upper = query.to_uppercase(); @@ -1560,7 +1640,7 @@ fn run_dataview_query(vault_path: String, query: String) -> Result = std::collections::HashMap::new(); fields_map.insert("title".into(), rel.replace(".md", "")); - if let Some(caps) = fm_re.captures(&content) { + if let Some(caps) = FRONTMATTER_RE.captures(&content) { for line in caps[1].lines() { if let Some(idx) = line.find(':') { let k = line[..idx].trim().to_lowercase(); @@ -1601,23 +1681,34 @@ fn git_status(vault_path: String) -> Result { .current_dir(&vault_path) .output() .map_err(|e| e.to_string())?; - Ok(String::from_utf8_lossy(&output.stdout).to_string()) + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } } #[tauri::command] fn git_commit(vault_path: String, message: String) -> Result { - let _ = Command::new("git") + let add_output = Command::new("git") .args(["add", "."]) .current_dir(&vault_path) .output() .map_err(|e| e.to_string())?; + if !add_output.status.success() { + return Err(String::from_utf8_lossy(&add_output.stderr).to_string()); + } let output = Command::new("git") .args(["commit", "-m", &message]) .current_dir(&vault_path) .output() .map_err(|e| e.to_string())?; - Ok(String::from_utf8_lossy(&output.stdout).to_string()) + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } } #[tauri::command] @@ -1655,7 +1746,11 @@ fn git_init(vault_path: String) -> Result { .current_dir(&vault_path) .output() .map_err(|e| e.to_string())?; - Ok(String::from_utf8_lossy(&output.stdout).to_string()) + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } } /* ── v0.8 Commands ──────────────────────────────────────── */ @@ -1780,7 +1875,8 @@ fn export_vault_zip(vault_path: String, output_path: String) -> Result