//! Bootstrap helpers for IronClaw. //! //! The only setting that truly needs disk persistence before the database is //! available is `DATABASE_URL` (chicken-and-egg: can't connect to DB without //! it). Everything else is auto-detected or read from env vars. //! //! File: `~/.ironclaw/.env` (standard dotenvy format) use std::path::PathBuf; use std::sync::LazyLock; const IRONCLAW_BASE_DIR_ENV: &str = "IRONCLAW_BASE_DIR"; /// Lazily computed IronClaw base directory, cached for the lifetime of the process. static IRONCLAW_BASE_DIR: LazyLock = LazyLock::new(compute_ironclaw_base_dir); /// Compute the IronClaw base directory from environment. /// /// This is the underlying implementation used by both the public /// `ironclaw_base_dir()` function (which caches the result) and tests /// (which need to verify different configurations). pub fn compute_ironclaw_base_dir() -> PathBuf { std::env::var(IRONCLAW_BASE_DIR_ENV) .map(PathBuf::from) .map(|path| { if path.as_os_str().is_empty() { default_base_dir() } else if !path.is_absolute() { eprintln!( "Warning: IRONCLAW_BASE_DIR is a relative path '{}', resolved against current directory", path.display() ); path } else { path } }) .unwrap_or_else(|_| default_base_dir()) } /// Get the default IronClaw base directory (~/.ironclaw). /// /// Logs a warning if the home directory cannot be determined and falls back to /// the current directory. fn default_base_dir() -> PathBuf { if let Some(home) = dirs::home_dir() { home.join(".ironclaw") } else { eprintln!("Warning: Could not determine home directory, using current directory"); std::env::current_dir() .unwrap_or_else(|_| PathBuf::from("/tmp")) .join(".ironclaw") } } /// Get the IronClaw base directory. /// /// Override with `IRONCLAW_BASE_DIR` environment variable. /// Defaults to `~/.ironclaw` (or `./.ironclaw` if home directory cannot be determined). /// /// Thread-safe: the value is computed once and cached in a `LazyLock`. /// /// # Environment Variable Behavior /// - If `IRONCLAW_BASE_DIR` is set to a non-empty path, that path is used. /// - If `IRONCLAW_BASE_DIR` is set to an empty string, it is treated as unset. /// - If `IRONCLAW_BASE_DIR` contains null bytes, a warning is printed and the default is used. /// - If the home directory cannot be determined, a warning is printed and the current directory is used. /// /// # Returns /// A `PathBuf` pointing to the base directory. The path is not validated /// for existence. pub fn ironclaw_base_dir() -> PathBuf { IRONCLAW_BASE_DIR.clone() } /// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`. pub fn ironclaw_env_path() -> PathBuf { ironclaw_base_dir().join(".env") } /// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`). /// /// Call this **after** `dotenvy::dotenv()` so that the standard `./.env` /// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites /// existing env vars, so the effective priority is: /// /// explicit env vars > `./.env` > `~/.ironclaw/.env` > auto-detect /// /// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does, /// extracts `DATABASE_URL` from it and writes the `.env` file (one-time /// upgrade from the old config format). /// /// After loading the `.env` file, auto-detects the libsql backend: if /// `DATABASE_BACKEND` is still unset and `~/.ironclaw/ironclaw.db` exists, /// defaults to `libsql` so cloud instances work out of the box without any /// manual configuration. pub fn load_ironclaw_env() { let path = ironclaw_env_path(); if !path.exists() { // One-time upgrade: extract DATABASE_URL from legacy bootstrap.json migrate_bootstrap_json_to_env(&path); } if path.exists() { let _ = dotenvy::from_path(&path); } // Auto-detect libsql: if DATABASE_BACKEND is still unset after loading // all env files, and the local SQLite DB exists, default to libsql. // This avoids the chicken-and-egg problem on cloud instances where no // DATABASE_URL is configured but ironclaw.db is already present. if std::env::var("DATABASE_BACKEND").is_err() { let default_db = dirs::home_dir() .unwrap_or_default() .join(".ironclaw") .join("ironclaw.db"); if default_db.exists() { if tokio::runtime::Handle::try_current().is_ok() { // Tokio runtime is active (multi-threaded); std::env::set_var is UB here. // Fall back to the thread-safe runtime overlay so the value is always set. tracing::warn!( "load_ironclaw_env called with active Tokio runtime; \ using runtime env overlay for DATABASE_BACKEND" ); crate::config::set_runtime_env("DATABASE_BACKEND", "libsql"); } else { // SAFETY: No Tokio runtime = no other threads = safe to call set_var. unsafe { std::env::set_var("DATABASE_BACKEND", "libsql") }; } } } } /// If `bootstrap.json` exists, pull `database_url` out of it and write `.env`. fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) { let ironclaw_dir = env_path .parent() .unwrap_or_else(|| std::path::Path::new(".")); let bootstrap_path = ironclaw_dir.join("bootstrap.json"); if !bootstrap_path.exists() { return; } let content = match std::fs::read_to_string(&bootstrap_path) { Ok(c) => c, Err(_) => return, }; // Minimal parse: just grab database_url from the JSON let parsed: serde_json::Value = match serde_json::from_str(&content) { Ok(v) => v, Err(_) => return, }; if let Some(url) = parsed.get("database_url").and_then(|v| v.as_str()) { if let Some(parent) = env_path.parent() && let Err(e) = std::fs::create_dir_all(parent) { eprintln!("Warning: failed to create {}: {}", parent.display(), e); return; } if let Err(e) = std::fs::write(env_path, format!("DATABASE_URL=\"{}\"\n", url)) { eprintln!("Warning: failed to migrate bootstrap.json to .env: {}", e); return; } rename_to_migrated(&bootstrap_path); eprintln!( "Migrated DATABASE_URL from bootstrap.json to {}", env_path.display() ); } } /// Write database bootstrap vars to `~/.ironclaw/.env`. /// /// These settings form the chicken-and-egg layer: they must be available /// from the filesystem (env vars) BEFORE any database connection, because /// they determine which database to connect to. Everything else is stored /// in the database itself. /// /// Creates the parent directory if it doesn't exist. /// Values are double-quoted so that `#` (common in URL-encoded passwords) /// and other shell-special characters are preserved by dotenvy. pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> { save_bootstrap_env_to(&ironclaw_env_path(), vars) } /// Write bootstrap vars to an arbitrary path (testable variant). /// /// Values are double-quoted and escaped so that `#`, `"`, `\` and other /// shell-special characters are preserved by dotenvy. pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let mut content = String::new(); for (key, value) in vars { // Escape backslashes and double quotes to prevent env var injection // (e.g. a value containing `"\nINJECTED="x` would break out of quotes). let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); content.push_str(&format!("{}=\"{}\"\n", key, escaped)); } std::fs::write(path, &content)?; restrict_file_permissions(path)?; Ok(()) } /// Update or add multiple variables in `~/.ironclaw/.env`, preserving existing content. /// /// Like `upsert_bootstrap_var` but batched — replaces lines for any key in `vars` /// and preserves all other existing lines. Use this instead of `save_bootstrap_env` /// when you want to update specific keys without destroying user-added variables. pub fn upsert_bootstrap_vars(vars: &[(&str, &str)]) -> std::io::Result<()> { upsert_bootstrap_vars_to(&ironclaw_env_path(), vars) } /// Update or add multiple variables at an arbitrary path (testable variant). pub fn upsert_bootstrap_vars_to( path: &std::path::Path, vars: &[(&str, &str)], ) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let keys_being_written: std::collections::HashSet<&str> = vars.iter().map(|(k, _)| *k).collect(); let existing = match std::fs::read_to_string(path) { Ok(contents) => contents, Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), Err(e) => return Err(e), }; let mut result = String::new(); for line in existing.lines() { // Extract key from lines matching `KEY=...` let is_overwritten = line .split_once('=') .map(|(k, _)| keys_being_written.contains(k.trim())) .unwrap_or(false); if !is_overwritten { result.push_str(line); result.push('\n'); } } // Append all new key=value pairs for (key, value) in vars { let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); result.push_str(&format!("{}=\"{}\"\n", key, escaped)); } std::fs::write(path, &result)?; restrict_file_permissions(path)?; Ok(()) } /// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content. /// /// Unlike `save_bootstrap_env` (which overwrites the entire file), this /// reads the current `.env`, replaces the line for `key` if it exists, /// or appends it otherwise. Use this when writing a single bootstrap var /// outside the wizard (which manages the full set via `save_bootstrap_env`). pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> { upsert_bootstrap_var_to(&ironclaw_env_path(), key, value) } /// Update or add a single variable at an arbitrary path (testable variant). pub fn upsert_bootstrap_var_to( path: &std::path::Path, key: &str, value: &str, ) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); let new_line = format!("{}=\"{}\"", key, escaped); let prefix = format!("{}=", key); let existing = std::fs::read_to_string(path).unwrap_or_default(); let mut found = false; let mut result = String::new(); for line in existing.lines() { if line.starts_with(&prefix) { if !found { result.push_str(&new_line); result.push('\n'); found = true; } // Skip duplicate lines for this key continue; } result.push_str(line); result.push('\n'); } if !found { result.push_str(&new_line); result.push('\n'); } std::fs::write(path, result)?; restrict_file_permissions(path)?; Ok(()) } /// Set restrictive file permissions (0o600) on Unix systems. /// /// The `.env` file may contain database credentials and API keys, /// so it should only be readable by the owner. fn restrict_file_permissions(_path: &std::path::Path) -> std::io::Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); std::fs::set_permissions(_path, perms)?; } Ok(()) } /// Write `DATABASE_URL` to `~/.ironclaw/.env`. /// /// Convenience wrapper around `save_bootstrap_env` for single-value migration /// paths. Prefer `save_bootstrap_env` for new code. pub fn save_database_url(url: &str) -> std::io::Result<()> { save_bootstrap_env(&[("DATABASE_URL", url)]) } /// One-time migration of legacy `~/.ironclaw/settings.json` into the database. /// /// Only runs when a `settings.json` exists on disk AND the DB has no settings /// yet. After the wizard writes directly to the DB, this path is only hit by /// users upgrading from the old disk-only configuration. /// /// After syncing, renames `settings.json` to `.migrated` so it won't trigger again. pub async fn migrate_disk_to_db( store: &dyn crate::db::Database, user_id: &str, ) -> Result<(), MigrationError> { let ironclaw_dir = ironclaw_base_dir(); let legacy_settings_path = ironclaw_dir.join("settings.json"); if !legacy_settings_path.exists() { tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration"); return Ok(()); } // If DB already has settings, this is not a first boot, the wizard already // wrote directly to the DB. Just clean up the stale file. let has_settings = store.has_settings(user_id).await.map_err(|e| { MigrationError::Database(format!("Failed to check existing settings: {}", e)) })?; if has_settings { tracing::info!("DB already has settings, renaming stale settings.json"); rename_to_migrated(&legacy_settings_path); return Ok(()); } tracing::info!("Migrating disk settings to database..."); // 1. Load and migrate settings.json let settings = crate::settings::Settings::load_from(&legacy_settings_path); let db_map = settings.to_db_map(); if !db_map.is_empty() { store .set_all_settings(user_id, &db_map) .await .map_err(|e| { MigrationError::Database(format!("Failed to write settings to DB: {}", e)) })?; tracing::info!("Migrated {} settings to database", db_map.len()); } // 2. Write DATABASE_URL to ~/.ironclaw/.env if let Some(ref url) = settings.database_url { save_database_url(url) .map_err(|e| MigrationError::Io(format!("Failed to write .env: {}", e)))?; tracing::info!("Wrote DATABASE_URL to {}", ironclaw_env_path().display()); } // 3. Migrate mcp-servers.json if it exists let mcp_path = ironclaw_dir.join("mcp-servers.json"); if mcp_path.exists() { match std::fs::read_to_string(&mcp_path) { Ok(content) => match serde_json::from_str::(&content) { Ok(value) => { store .set_setting(user_id, "mcp_servers", &value) .await .map_err(|e| { MigrationError::Database(format!( "Failed to write MCP servers to DB: {}", e )) })?; tracing::info!("Migrated mcp-servers.json to database"); rename_to_migrated(&mcp_path); } Err(e) => { tracing::warn!("Failed to parse mcp-servers.json: {}", e); } }, Err(e) => { tracing::warn!("Failed to read mcp-servers.json: {}", e); } } } // 4. Migrate session.json if it exists let session_path = ironclaw_dir.join("session.json"); if session_path.exists() { match std::fs::read_to_string(&session_path) { Ok(content) => match serde_json::from_str::(&content) { Ok(value) => { store .set_setting(user_id, "nearai.session_token", &value) .await .map_err(|e| { MigrationError::Database(format!( "Failed to write session to DB: {}", e )) })?; tracing::info!("Migrated session.json to database"); rename_to_migrated(&session_path); } Err(e) => { tracing::warn!("Failed to parse session.json: {}", e); } }, Err(e) => { tracing::warn!("Failed to read session.json: {}", e); } } } // 5. Rename settings.json to .migrated (don't delete, safety net) rename_to_migrated(&legacy_settings_path); // 6. Clean up old bootstrap.json if it exists (superseded by .env) let old_bootstrap = ironclaw_dir.join("bootstrap.json"); if old_bootstrap.exists() { rename_to_migrated(&old_bootstrap); tracing::info!("Renamed old bootstrap.json to .migrated"); } tracing::info!("Disk-to-DB migration complete"); Ok(()) } /// Rename a file to `.migrated` as a safety net. fn rename_to_migrated(path: &std::path::Path) { let mut migrated = path.as_os_str().to_owned(); migrated.push(".migrated"); if let Err(e) = std::fs::rename(path, &migrated) { tracing::warn!("Failed to rename {} to .migrated: {}", path.display(), e); } } /// Errors that can occur during disk-to-DB migration. #[derive(Debug, thiserror::Error)] pub enum MigrationError { #[error("Database error: {0}")] Database(String), #[error("IO error: {0}")] Io(String), } // ── PID Lock ────────────────────────────────────────────────────────────── /// Path to the PID lock file: `~/.ironclaw/ironclaw.pid`. pub fn pid_lock_path() -> PathBuf { ironclaw_base_dir().join("ironclaw.pid") } /// A PID-based lock that prevents multiple IronClaw instances from running /// simultaneously. /// /// Uses `fs4::try_lock_exclusive()` for atomic locking (no TOCTOU race), /// then writes the current PID into the locked file for diagnostics. /// The OS-level lock is held for the lifetime of this struct and /// automatically released on drop (along with the PID file cleanup). #[derive(Debug)] pub struct PidLock { path: PathBuf, /// Held open to maintain the OS-level exclusive lock. _file: std::fs::File, } /// Errors from PID lock acquisition. #[derive(Debug, thiserror::Error)] pub enum PidLockError { #[error("Another IronClaw instance is already running (PID {pid})")] AlreadyRunning { pid: u32 }, #[error("Failed to acquire PID lock: {0}")] Io(#[from] std::io::Error), } impl PidLock { /// Try to acquire the PID lock. /// /// Uses an exclusive file lock (`flock`/`LockFileEx`) so that two /// concurrent processes cannot both acquire the lock — no TOCTOU race. /// If the lock file exists but the holding process is gone (stale), /// the lock is reclaimed automatically by the OS. pub fn acquire() -> Result { Self::acquire_at(pid_lock_path()) } /// Acquire at a specific path (for testing). fn acquire_at(path: PathBuf) -> Result { use fs4::FileExt; use std::fs::OpenOptions; use std::io::Write; // Ensure parent directory exists if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } // Open (or create) the lock file let mut file = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .open(&path)?; // Try non-blocking exclusive lock — if another process holds it, // this fails immediately instead of blocking. if let Err(e) = file.try_lock_exclusive() { if e.kind() == std::io::ErrorKind::WouldBlock { // Lock held by another process — read its PID for the error message let pid = std::fs::read_to_string(&path) .ok() .and_then(|s| s.trim().parse::().ok()) .unwrap_or(0); return Err(PidLockError::AlreadyRunning { pid }); } // Other errors (permissions, unsupported filesystem, etc.) return Err(PidLockError::Io(e)); } // We hold the exclusive lock — write our PID file.set_len(0)?; // truncate write!(file, "{}", std::process::id())?; Ok(PidLock { path, _file: file }) } } impl Drop for PidLock { fn drop(&mut self) { // Remove the PID file; the OS-level lock is released when _file is dropped. let _ = std::fs::remove_file(&self.path); } } #[cfg(test)] mod tests { use super::*; use std::process::Command; use std::sync::Mutex; use std::thread; use std::time::{Duration, Instant}; use tempfile::tempdir; static ENV_MUTEX: Mutex<()> = Mutex::new(()); #[test] fn test_save_and_load_database_url() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // Write in the quoted format that save_database_url uses let url = "postgres://localhost:5432/ironclaw_test"; std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap(); // Verify the content is a valid dotenv line (quoted) let content = std::fs::read_to_string(&env_path).unwrap(); assert_eq!( content, "DATABASE_URL=\"postgres://localhost:5432/ironclaw_test\"\n" ); // Verify dotenvy can parse it (strips quotes automatically) let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!(parsed.len(), 1); assert_eq!(parsed[0].0, "DATABASE_URL"); assert_eq!(parsed[0].1, url); } #[test] fn test_save_database_url_with_hash_in_password() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // URLs with # in the password are common (URL-encoded special chars). // Without quoting, dotenvy treats # as a comment delimiter. let url = "postgres://user:p%23ss@localhost:5432/ironclaw"; std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap(); let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!(parsed.len(), 1); assert_eq!(parsed[0].0, "DATABASE_URL"); assert_eq!(parsed[0].1, url); } #[test] fn test_save_database_url_creates_parent_dirs() { let dir = tempdir().unwrap(); let nested = dir.path().join("deep").join("nested"); let env_path = nested.join(".env"); // Parent doesn't exist yet assert!(!nested.exists()); // The global function uses a fixed path, so we test the logic directly std::fs::create_dir_all(&nested).unwrap(); std::fs::write(&env_path, "DATABASE_URL=postgres://test\n").unwrap(); assert!(env_path.exists()); let content = std::fs::read_to_string(&env_path).unwrap(); assert!(content.contains("DATABASE_URL=postgres://test")); } #[test] fn test_save_bootstrap_env_escapes_quotes() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // A malicious URL attempting to inject a second env var let malicious = r#"http://evil.com" INJECTED="pwned"#; let mut content = String::new(); let escaped = malicious.replace('\\', "\\\\").replace('"', "\\\""); content.push_str(&format!("LLM_BASE_URL=\"{}\"\n", escaped)); std::fs::write(&env_path, &content).unwrap(); let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); // Must parse as exactly one variable, not two assert_eq!(parsed.len(), 1, "injection must not create extra vars"); assert_eq!(parsed[0].0, "LLM_BASE_URL"); // The value should contain the original malicious content (unescaped by dotenvy) assert!( parsed[0].1.contains("INJECTED"), "value should contain the literal injection attempt, not execute it" ); } #[test] fn test_ironclaw_env_path() { let path = ironclaw_env_path(); assert!(path.ends_with(".ironclaw/.env")); } #[test] fn test_migrate_bootstrap_json_to_env() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); let bootstrap_path = dir.path().join("bootstrap.json"); // Write a legacy bootstrap.json let bootstrap_json = serde_json::json!({ "database_url": "postgres://localhost/ironclaw_upgrade", "database_pool_size": 5, "secrets_master_key_source": "keychain", "onboard_completed": true }); std::fs::write( &bootstrap_path, serde_json::to_string_pretty(&bootstrap_json).unwrap(), ) .unwrap(); assert!(!env_path.exists()); assert!(bootstrap_path.exists()); // Run the migration migrate_bootstrap_json_to_env(&env_path); // .env should now exist with DATABASE_URL assert!(env_path.exists()); let content = std::fs::read_to_string(&env_path).unwrap(); assert_eq!( content, "DATABASE_URL=\"postgres://localhost/ironclaw_upgrade\"\n" ); // bootstrap.json should be renamed to .migrated assert!(!bootstrap_path.exists()); assert!(dir.path().join("bootstrap.json.migrated").exists()); } #[test] fn test_migrate_bootstrap_json_no_database_url() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); let bootstrap_path = dir.path().join("bootstrap.json"); // bootstrap.json with no database_url let bootstrap_json = serde_json::json!({ "onboard_completed": false }); std::fs::write( &bootstrap_path, serde_json::to_string_pretty(&bootstrap_json).unwrap(), ) .unwrap(); migrate_bootstrap_json_to_env(&env_path); // .env should NOT be created assert!(!env_path.exists()); // bootstrap.json should remain (no migration happened) assert!(bootstrap_path.exists()); } #[test] fn test_migrate_bootstrap_json_missing() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // No bootstrap.json at all migrate_bootstrap_json_to_env(&env_path); // Nothing should happen assert!(!env_path.exists()); } #[test] fn test_save_bootstrap_env_multiple_vars() { let dir = tempdir().unwrap(); let env_path = dir.path().join("nested").join(".env"); std::fs::create_dir_all(env_path.parent().unwrap()).unwrap(); let vars = [ ("DATABASE_BACKEND", "libsql"), ("LIBSQL_PATH", "/home/user/.ironclaw/ironclaw.db"), ]; // Write manually to the temp path (save_bootstrap_env uses the global path) let mut content = String::new(); for (key, value) in &vars { content.push_str(&format!("{}=\"{}\"\n", key, value)); } std::fs::write(&env_path, &content).unwrap(); // Verify dotenvy can parse all entries let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!(parsed.len(), 2); assert_eq!( parsed[0], ("DATABASE_BACKEND".to_string(), "libsql".to_string()) ); assert_eq!( parsed[1], ( "LIBSQL_PATH".to_string(), "/home/user/.ironclaw/ironclaw.db".to_string() ) ); } #[test] fn test_save_bootstrap_env_overwrites_previous() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // Write initial content std::fs::write(&env_path, "DATABASE_URL=\"postgres://old\"\n").unwrap(); // Overwrite with new vars (simulating save_bootstrap_env behavior) let content = "DATABASE_BACKEND=\"libsql\"\nLIBSQL_PATH=\"/new/path.db\"\n"; std::fs::write(&env_path, content).unwrap(); let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); // Old DATABASE_URL should be gone assert_eq!(parsed.len(), 2); assert!(parsed.iter().all(|(k, _)| k != "DATABASE_URL")); } #[test] fn test_onboard_completed_round_trips_through_env() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // Simulate what the wizard writes: bootstrap vars + ONBOARD_COMPLETED let vars = [ ("DATABASE_BACKEND", "libsql"), ("ONBOARD_COMPLETED", "true"), ]; let mut content = String::new(); for (key, value) in &vars { let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); content.push_str(&format!("{}=\"{}\"\n", key, escaped)); } std::fs::write(&env_path, &content).unwrap(); // Verify dotenvy parses ONBOARD_COMPLETED correctly let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!(parsed.len(), 2); let onboard = parsed.iter().find(|(k, _)| k == "ONBOARD_COMPLETED"); assert!(onboard.is_some(), "ONBOARD_COMPLETED must be present"); assert_eq!(onboard.unwrap().1, "true"); } #[test] fn test_libsql_autodetect_sets_backend_when_db_exists() { let _guard = ENV_MUTEX.lock().unwrap(); let old_val = std::env::var("DATABASE_BACKEND").ok(); // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::remove_var("DATABASE_BACKEND") }; let dir = tempdir().unwrap(); let db_path = dir.path().join("ironclaw.db"); // No DB file — auto-detect guard should not trigger. assert!(!db_path.exists()); let would_trigger = std::env::var("DATABASE_BACKEND").is_err() && db_path.exists(); assert!( !would_trigger, "should not auto-detect when db file is absent" ); // Create the DB file — guard should now trigger. std::fs::write(&db_path, "").unwrap(); assert!(db_path.exists()); // Simulate the detection logic (DATABASE_BACKEND unset + db exists). let detected = std::env::var("DATABASE_BACKEND").is_err() && db_path.exists(); assert!( detected, "should detect libsql when db file is present and backend unset" ); // Restore. if let Some(val) = old_val { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("DATABASE_BACKEND", val) }; } } // === QA Plan P1 - 1.2: Bootstrap .env round-trip tests === #[test] fn bootstrap_env_round_trips_llm_backend() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // Simulate what the wizard writes for LLM backend selection let vars = [ ("DATABASE_BACKEND", "libsql"), ("LLM_BACKEND", "openai"), ("ONBOARD_COMPLETED", "true"), ]; let mut content = String::new(); for (key, value) in &vars { let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); content.push_str(&format!("{}=\"{}\"\n", key, escaped)); } std::fs::write(&env_path, &content).unwrap(); // Verify dotenvy parses LLM_BACKEND correctly let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); let llm_backend = parsed.iter().find(|(k, _)| k == "LLM_BACKEND"); assert!(llm_backend.is_some(), "LLM_BACKEND must be present"); assert_eq!( llm_backend.unwrap().1, "openai", "LLM_BACKEND must survive .env round-trip" ); } #[test] fn test_libsql_autodetect_does_not_override_explicit_backend() { let _guard = ENV_MUTEX.lock().unwrap(); let old_val = std::env::var("DATABASE_BACKEND").ok(); // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("DATABASE_BACKEND", "postgres") }; let dir = tempdir().unwrap(); let db_path = dir.path().join("ironclaw.db"); std::fs::write(&db_path, "").unwrap(); // The guard: only sets libsql if DATABASE_BACKEND is NOT already set. let would_override = std::env::var("DATABASE_BACKEND").is_err() && db_path.exists(); assert!( !would_override, "must not override an explicitly set DATABASE_BACKEND" ); // Restore. if let Some(val) = old_val { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("DATABASE_BACKEND", val) }; } else { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::remove_var("DATABASE_BACKEND") }; } } #[test] fn bootstrap_env_special_chars_in_url() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // URLs with special characters that are common in database passwords let url = "postgres://user:p%23ss@host:5432/db?sslmode=require"; let escaped = url.replace('\\', "\\\\").replace('"', "\\\""); let content = format!("DATABASE_URL=\"{}\"\n", escaped); std::fs::write(&env_path, &content).unwrap(); let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!(parsed.len(), 1); assert_eq!(parsed[0].1, url, "URL with special chars must survive"); } #[test] fn upsert_bootstrap_var_preserves_existing() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // Write initial content let initial = "DATABASE_BACKEND=\"libsql\"\nONBOARD_COMPLETED=\"true\"\n"; std::fs::write(&env_path, initial).unwrap(); // Upsert a new var let content = std::fs::read_to_string(&env_path).unwrap(); let new_line = "LLM_BACKEND=\"anthropic\""; let mut result = content.clone(); result.push_str(new_line); result.push('\n'); std::fs::write(&env_path, &result).unwrap(); // Parse and verify all three vars are present let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!(parsed.len(), 3, "should have 3 vars after upsert"); assert!( parsed .iter() .any(|(k, v)| k == "DATABASE_BACKEND" && v == "libsql"), "original DATABASE_BACKEND must be preserved" ); assert!( parsed .iter() .any(|(k, v)| k == "ONBOARD_COMPLETED" && v == "true"), "original ONBOARD_COMPLETED must be preserved" ); assert!( parsed .iter() .any(|(k, v)| k == "LLM_BACKEND" && v == "anthropic"), "new LLM_BACKEND must be present" ); } #[test] fn bootstrap_env_all_wizard_vars_round_trip() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // Full set of vars the wizard might write let vars = [ ("DATABASE_BACKEND", "postgres"), ("DATABASE_URL", "postgres://u:p@h:5432/db"), ("LLM_BACKEND", "nearai"), ("ONBOARD_COMPLETED", "true"), ("EMBEDDING_ENABLED", "false"), ]; let mut content = String::new(); for (key, value) in &vars { let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); content.push_str(&format!("{}=\"{}\"\n", key, escaped)); } std::fs::write(&env_path, &content).unwrap(); let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!(parsed.len(), vars.len(), "all vars must survive round-trip"); for (key, value) in &vars { let found = parsed.iter().find(|(k, _)| k == key); assert!(found.is_some(), "{key} must be present"); assert_eq!(&found.unwrap().1, value, "{key} value mismatch"); } } #[test] fn test_ironclaw_base_dir_default() { // This test must run first (or in isolation) before the LazyLock is initialized. // It verifies that when IRONCLAW_BASE_DIR is not set, the default path is used. let _guard = ENV_MUTEX.lock().unwrap(); let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; // Force re-evaluation by calling the computation function directly let path = compute_ironclaw_base_dir(); let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); assert_eq!(path, home.join(".ironclaw")); if let Some(val) = old_val { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; } } #[test] fn test_ironclaw_base_dir_env_override() { // This test verifies that when IRONCLAW_BASE_DIR is set, // the custom path is used. Must run before LazyLock is initialized. let _guard = ENV_MUTEX.lock().unwrap(); let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/custom/ironclaw/path") }; // Force re-evaluation by calling the computation function directly let path = compute_ironclaw_base_dir(); assert_eq!(path, std::path::PathBuf::from("/custom/ironclaw/path")); if let Some(val) = old_val { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; } else { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; } } #[test] fn test_compute_base_dir_env_path_join() { // Verifies that ironclaw_env_path correctly joins .env to the base dir. // Uses compute_ironclaw_base_dir directly to avoid LazyLock caching. let _guard = ENV_MUTEX.lock().unwrap(); let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/my/custom/dir") }; // Test the path construction logic directly let base_path = compute_ironclaw_base_dir(); let env_path = base_path.join(".env"); assert_eq!(env_path, std::path::PathBuf::from("/my/custom/dir/.env")); if let Some(val) = old_val { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; } else { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; } } #[test] fn test_ironclaw_base_dir_empty_env() { // Verifies that empty IRONCLAW_BASE_DIR falls back to default. let _guard = ENV_MUTEX.lock().unwrap(); let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "") }; // Force re-evaluation by calling the computation function directly let path = compute_ironclaw_base_dir(); let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); assert_eq!(path, home.join(".ironclaw")); if let Some(val) = old_val { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; } else { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; } } #[test] fn test_ironclaw_base_dir_special_chars() { // Verifies that paths with special characters are handled correctly. let _guard = ENV_MUTEX.lock().unwrap(); let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/tmp/test_with-special.chars") }; // Force re-evaluation by calling the computation function directly let path = compute_ironclaw_base_dir(); assert_eq!( path, std::path::PathBuf::from("/tmp/test_with-special.chars") ); if let Some(val) = old_val { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; } else { // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; } } // ── PID Lock tests ─────────────────────────────────────────────── #[test] fn test_pid_lock_acquire_and_drop() { let dir = tempdir().unwrap(); let pid_path = dir.path().join("ironclaw.pid"); // Acquire lock let lock = PidLock::acquire_at(pid_path.clone()).unwrap(); assert!(pid_path.exists()); // PID file should contain our PID let contents = std::fs::read_to_string(&pid_path).unwrap(); assert_eq!(contents.trim().parse::().unwrap(), std::process::id()); // Drop should remove the file drop(lock); assert!(!pid_path.exists()); } #[test] fn test_pid_lock_rejects_second_acquire() { let dir = tempdir().unwrap(); let pid_path = dir.path().join("ironclaw.pid"); // First lock succeeds let _lock1 = PidLock::acquire_at(pid_path.clone()).unwrap(); // Second acquire on same file must fail (exclusive flock held) let result = PidLock::acquire_at(pid_path.clone()); assert!(result.is_err()); match result.unwrap_err() { PidLockError::AlreadyRunning { pid } => { assert_eq!(pid, std::process::id()); } other => panic!("expected AlreadyRunning, got: {}", other), } } #[test] fn test_pid_lock_reclaims_after_drop() { let dir = tempdir().unwrap(); let pid_path = dir.path().join("ironclaw.pid"); // Acquire and release let lock = PidLock::acquire_at(pid_path.clone()).unwrap(); drop(lock); // Should succeed — OS lock was released on drop let lock2 = PidLock::acquire_at(pid_path).unwrap(); drop(lock2); } #[test] fn test_pid_lock_reclaims_stale_file_without_flock() { let dir = tempdir().unwrap(); let pid_path = dir.path().join("ironclaw.pid"); // Write a stale PID file manually (no flock held) std::fs::write(&pid_path, "4294967294").unwrap(); // Should succeed because no OS lock is held on the file let lock = PidLock::acquire_at(pid_path.clone()).unwrap(); let contents = std::fs::read_to_string(&pid_path).unwrap(); assert_eq!(contents.trim().parse::().unwrap(), std::process::id()); drop(lock); } #[test] fn test_pid_lock_handles_corrupt_pid_file() { let dir = tempdir().unwrap(); let pid_path = dir.path().join("ironclaw.pid"); // Write garbage (no flock held) std::fs::write(&pid_path, "not-a-number").unwrap(); // Should succeed — no OS lock held, file is reclaimed let lock = PidLock::acquire_at(pid_path).unwrap(); drop(lock); } #[test] fn test_pid_lock_creates_parent_dirs() { let dir = tempdir().unwrap(); let pid_path = dir.path().join("nested").join("deep").join("ironclaw.pid"); let lock = PidLock::acquire_at(pid_path.clone()).unwrap(); assert!(pid_path.exists()); drop(lock); } #[test] fn test_pid_lock_child_helper_holds_lock() { if std::env::var("IRONCLAW_PID_LOCK_CHILD").ok().as_deref() != Some("1") { return; } let pid_path = PathBuf::from( std::env::var("IRONCLAW_PID_LOCK_PATH").expect("IRONCLAW_PID_LOCK_PATH missing"), ); let hold_ms = std::env::var("IRONCLAW_PID_LOCK_HOLD_MS") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(3000); let _lock = PidLock::acquire_at(pid_path).expect("child failed to acquire pid lock"); thread::sleep(Duration::from_millis(hold_ms)); } #[test] fn test_pid_lock_rejects_lock_held_by_other_process() { let dir = tempdir().unwrap(); let pid_path = dir.path().join("ironclaw.pid"); let current_exe = std::env::current_exe().unwrap(); let mut child = Command::new(current_exe) .args([ "--exact", "bootstrap::tests::test_pid_lock_child_helper_holds_lock", "--nocapture", "--test-threads=1", ]) .env("IRONCLAW_PID_LOCK_CHILD", "1") .env("IRONCLAW_PID_LOCK_PATH", pid_path.display().to_string()) .env("IRONCLAW_PID_LOCK_HOLD_MS", "3000") .spawn() .unwrap(); let started = Instant::now(); while started.elapsed() < Duration::from_secs(2) { if pid_path.exists() { break; } if let Some(status) = child.try_wait().unwrap() { panic!("child exited before acquiring lock: {}", status); } thread::sleep(Duration::from_millis(20)); } assert!( pid_path.exists(), "child did not create lock file in time: {}", pid_path.display() ); let result = PidLock::acquire_at(pid_path.clone()); match result.unwrap_err() { PidLockError::AlreadyRunning { .. } => {} other => panic!("expected AlreadyRunning, got: {}", other), } let status = child.wait().unwrap(); assert!(status.success(), "child process failed: {}", status); // After the child exits, lock should be released and reacquirable. let lock = PidLock::acquire_at(pid_path).unwrap(); drop(lock); } #[test] fn upsert_bootstrap_vars_preserves_unknown_keys() { let dir = tempdir().unwrap(); let env_path = dir.path().join(".env"); // Simulate a user-edited .env with custom vars let initial = "HTTP_HOST=\"0.0.0.0\"\nDATABASE_BACKEND=\"postgres\"\nCUSTOM_VAR=\"keep_me\"\n"; std::fs::write(&env_path, initial).unwrap(); // Upsert wizard vars — should preserve HTTP_HOST and CUSTOM_VAR let vars = [("DATABASE_BACKEND", "libsql"), ("LLM_BACKEND", "openai")]; upsert_bootstrap_vars_to(&env_path, &vars).unwrap(); let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!( parsed.len(), 4, "should have 4 vars (2 preserved + 2 upserted)" ); // User-added vars must be preserved assert!( parsed .iter() .any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"), "HTTP_HOST must be preserved" ); assert!( parsed .iter() .any(|(k, v)| k == "CUSTOM_VAR" && v == "keep_me"), "CUSTOM_VAR must be preserved" ); // Wizard vars must be updated/added assert!( parsed .iter() .any(|(k, v)| k == "DATABASE_BACKEND" && v == "libsql"), "DATABASE_BACKEND must be updated to libsql" ); assert!( parsed .iter() .any(|(k, v)| k == "LLM_BACKEND" && v == "openai"), "LLM_BACKEND must be added" ); // Now update LLM_BACKEND and verify HTTP_HOST still preserved let vars2 = [("LLM_BACKEND", "anthropic")]; upsert_bootstrap_vars_to(&env_path, &vars2).unwrap(); let parsed2: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!( parsed2.len(), 4, "should still have 4 vars after second upsert" ); assert!( parsed2 .iter() .any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"), "HTTP_HOST must still be preserved after second upsert" ); assert!( parsed2 .iter() .any(|(k, v)| k == "LLM_BACKEND" && v == "anthropic"), "LLM_BACKEND must be updated to anthropic" ); } #[test] fn upsert_bootstrap_vars_creates_file_if_missing() { let dir = tempdir().unwrap(); let env_path = dir.path().join("subdir").join(".env"); // File doesn't exist yet assert!(!env_path.exists()); let vars = [("DATABASE_BACKEND", "libsql")]; upsert_bootstrap_vars_to(&env_path, &vars).unwrap(); assert!(env_path.exists()); let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path) .unwrap() .filter_map(|r| r.ok()) .collect(); assert_eq!(parsed.len(), 1); assert_eq!( parsed[0], ("DATABASE_BACKEND".to_string(), "libsql".to_string()) ); } }