mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* fix(tests): eliminate env mutex poison cascade and fix test flakiness The shared ENV_MUTEX used by ~68 config tests would cascade a single test panic into failures across every module. Replace all .unwrap() / .expect() lock acquisitions with a poison-recovering lock_env() helper. Consolidate rogue module-local ENV_LOCK instances (workspace, orchestrator, bootstrap) onto the shared global mutex to prevent cross-module races. Also fixes: - gateway user_id fallback was hardcoded to "default" instead of owner_id - test_ironclaw_env_path used LazyLock which is order-dependent Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test(helpers): add regression test for lock_env poison recovery Satisfies the regression-test-check CI gate by adding a test that intentionally poisons ENV_MUTEX and verifies lock_env() recovers. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(ci): detect test changes inside #[cfg(test)] regions The regression test check relied on git diff -W to expand context to function boundaries, but git doesn't recognize Rust `mod tests {}` as a function boundary. Changes to imports, helpers, or lock calls inside test modules were invisible to the check. Add a line-level fallback: for each changed .rs file, find where #[cfg(test)] starts and check if any diff hunk targets a line at or after that boundary. This catches edits anywhere inside test modules regardless of git's language awareness. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review feedback - Clear ENV_MUTEX poison after regression test so it doesn't leave global state dirty for subsequent tests. - Fix CI regression-test-check to match #[cfg(test)] only when followed by `mod` (the test module pattern), avoiding false positives from standalone #[cfg(test)] items like statics or functions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1419 lines
51 KiB
Rust
1419 lines
51 KiB
Rust
//! 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<PathBuf> = 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::<serde_json::Value>(&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::<serde_json::Value>(&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 `<name>.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, PidLockError> {
|
|
Self::acquire_at(pid_lock_path())
|
|
}
|
|
|
|
/// Acquire at a specific path (for testing).
|
|
fn acquire_at(path: PathBuf) -> Result<Self, PidLockError> {
|
|
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::<u32>().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 crate::config::helpers::lock_env;
|
|
use std::process::Command;
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
use tempfile::tempdir;
|
|
|
|
#[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() {
|
|
// Use compute_ironclaw_base_dir() directly to avoid LazyLock caching,
|
|
// which can be poisoned by whichever test initializes it first.
|
|
let _guard = lock_env();
|
|
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
|
// SAFETY: Under lock_env(), no concurrent env access.
|
|
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
|
|
|
|
let path = compute_ironclaw_base_dir().join(".env");
|
|
assert!(
|
|
path.ends_with(".ironclaw/.env"),
|
|
"expected path ending with .ironclaw/.env, got: {}",
|
|
path.display()
|
|
);
|
|
|
|
if let Some(val) = old_val {
|
|
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
|
|
}
|
|
}
|
|
|
|
#[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 = lock_env();
|
|
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 = lock_env();
|
|
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 = lock_env();
|
|
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 = lock_env();
|
|
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 = lock_env();
|
|
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 = lock_env();
|
|
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 = lock_env();
|
|
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::<u32>().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::<u32>().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::<u64>().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())
|
|
);
|
|
}
|
|
}
|