use rusqlite::Connection;
use solo_core::{Embedder, Error, Result, TenantId};
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
use crate::{
config::{EmbedderConfig, LlmSettings, SoloConfig},
key_material::KeyMaterial,
lockfile::Lockfile,
migration,
path_validation::validate_data_dir,
tenants::{
TENANTS_INDEX_FILENAME, TENANTS_SUBDIR, TenantStatus, TenantsIndex, migrate_v071_to_v080,
},
};
pub fn default_data_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".solo"))
}
const SOLO_OWNED_FILES_ROOT: &[&str] = &[
"solo.db",
"solo.db-wal",
"solo.db-shm",
"hnsw_episodes.hnsw.data",
"hnsw_episodes.hnsw.graph",
"hnsw_episodes_bak.hnsw.data",
"hnsw_episodes_bak.hnsw.graph",
"hnsw_episodes_tmp.hnsw.data",
"hnsw_episodes_tmp.hnsw.graph",
"solo.config.toml",
"solo.config.toml.tmp",
"solo.lock",
TENANTS_INDEX_FILENAME,
"tenants_index.db-wal",
"tenants_index.db-shm",
];
#[derive(Debug, Clone)]
pub struct InitParams {
pub data_dir: PathBuf,
pub passphrase: Zeroizing<String>,
pub force: bool,
pub embedder: EmbedderConfig,
}
pub fn default_embedder() -> EmbedderConfig {
let stub = crate::embedder::StubEmbedder::default_stub();
EmbedderConfig {
name: stub.name().to_string(),
version: stub.version().to_string(),
dim: stub.dim() as u32,
dtype: "f32".into(),
}
}
pub fn default_llm_settings_from_env() -> LlmSettings {
fn env_non_empty(name: &str) -> bool {
std::env::var(name)
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
}
if env_non_empty("ANTHROPIC_API_KEY") {
LlmSettings::Anthropic {
api_key_env: "ANTHROPIC_API_KEY".to_string(),
model: "claude-sonnet-4-6".to_string(),
}
} else {
LlmSettings::None
}
}
#[derive(Debug)]
pub struct InitOutcome {
pub data_dir: PathBuf,
pub db_path: PathBuf,
pub config_path: PathBuf,
pub schema_version: u32,
pub tenants_index_path: PathBuf,
pub tenants_index_schema_version: u32,
pub upgraded_from_v071: bool,
}
pub fn init(params: InitParams) -> Result<InitOutcome> {
let InitParams {
data_dir,
passphrase,
force,
embedder,
} = params;
if passphrase.is_empty() {
return Err(Error::invalid_input(
"passphrase must not be empty (Solo uses it to derive the SQLCipher key)",
));
}
validate_data_dir(&data_dir)?;
let config_path = data_dir.join("solo.config.toml");
let lock_path = data_dir.join("solo.lock");
let tenants_index_path = data_dir.join(TENANTS_INDEX_FILENAME);
let tenants_dir = data_dir.join(TENANTS_SUBDIR);
let legacy_db_path = data_dir.join("solo.db");
let new_default_db_path = tenants_dir.join("default.db");
let has_v071_db = legacy_db_path.is_file();
let has_v080_index = tenants_index_path.is_file();
let has_config = config_path.is_file();
std::fs::create_dir_all(&data_dir)
.map_err(|e| Error::storage(format!("create data dir {}: {e}", data_dir.display())))?;
let _lock = Lockfile::acquire(&lock_path)?;
if has_v080_index && !force {
return Err(Error::conflict(format!(
"data directory is already initialized (v0.8.0 layout): {}\n\
Re-run with --force to wipe and re-initialize \
(DESTRUCTIVE — all stored memories will be lost).",
data_dir.display()
)));
}
if has_v080_index && force {
wipe_solo_owned_files(&data_dir)?;
} else if has_v071_db && !has_v080_index {
if !has_config {
return Err(Error::conflict(format!(
"data dir has a v0.7.1 solo.db but no solo.config.toml: {}\n\
Cannot upgrade in place without the persisted salt. \
Either restore the missing config or use --force to wipe.",
data_dir.display()
)));
}
if force {
wipe_solo_owned_files(&data_dir)?;
} else {
let cfg = SoloConfig::read(&config_path)
.map_err(|e| Error::storage(format!("read config for v0.7.1 upgrade: {e}")))?;
let salt = cfg.salt_bytes()?;
let key = KeyMaterial::derive(&passphrase, &salt)?;
migrate_v071_to_v080(&data_dir, &key)?;
let conn = open_sqlcipher(&new_default_db_path, &key)?;
let schema_version: u32 = conn
.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
row.get(0)
})
.map_err(|e| {
Error::storage(format!("verify v0.7.1 upgrade cipher round-trip: {e}"))
})?;
drop(conn);
let tenants_index_version = {
let conn = open_sqlcipher(&tenants_index_path, &key)?;
migration::current_tenants_index_version(&conn)?
};
return Ok(InitOutcome {
data_dir,
db_path: new_default_db_path,
config_path,
schema_version,
tenants_index_path,
tenants_index_schema_version: tenants_index_version,
upgraded_from_v071: true,
});
}
}
let salt = KeyMaterial::fresh_salt()?;
let key = KeyMaterial::derive(&passphrase, &salt)?;
std::fs::create_dir_all(&tenants_dir).map_err(|e| {
Error::storage(format!(
"create tenants subdir {}: {e}",
tenants_dir.display()
))
})?;
let mut index = TenantsIndex::open(&data_dir, &key)?;
let mut tenant_conn = open_sqlcipher(&new_default_db_path, &key)?;
let schema_version = migration::run_migrations(&mut tenant_conn)?;
drop(tenant_conn);
let conn2 = open_sqlcipher(&new_default_db_path, &key)?;
let highest: u32 = conn2
.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
row.get(0)
})
.map_err(|e| Error::storage(format!("verify cipher round-trip (default tenant): {e}")))?;
drop(conn2);
if highest != schema_version {
return Err(Error::storage(format!(
"cipher round-trip read drift (default tenant): wrote {schema_version}, read {highest}"
)));
}
let default_id = TenantId::default_tenant();
if index.lookup(&default_id)?.is_none() {
index.register_with_status(
&default_id,
"default.db",
Some("Default tenant"),
TenantStatus::Active,
)?;
}
let tenants_index_version = migration::current_tenants_index_version(index.connection())?;
drop(index);
let mut cfg = SoloConfig::new(salt, embedder);
cfg.llm = Some(default_llm_settings_from_env());
cfg.write(&config_path)?;
Ok(InitOutcome {
data_dir,
db_path: new_default_db_path,
config_path,
schema_version,
tenants_index_path,
tenants_index_schema_version: tenants_index_version,
upgraded_from_v071: false,
})
}
pub fn open_sqlcipher(db_path: &Path, key: &KeyMaterial) -> Result<Connection> {
let conn = Connection::open(db_path)
.map_err(|e| Error::storage(format!("open {}: {e}", db_path.display())))?;
let key_pragma: zeroize::Zeroizing<String> = {
let hex = key.as_hex();
zeroize::Zeroizing::new(format!("PRAGMA key = \"x'{}'\"", &*hex))
};
conn.execute_batch(&key_pragma)
.map_err(|e| Error::storage(format!("PRAGMA key: {e}")))?;
let mode: String = conn
.query_row("PRAGMA journal_mode = wal", [], |row| row.get(0))
.map_err(|e| Error::storage(format!("set journal_mode=wal: {e}")))?;
if mode.to_lowercase() != "wal" {
return Err(Error::storage(format!(
"expected WAL journal mode, got {mode}"
)));
}
conn.execute_batch(
"PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;",
)
.map_err(|e| Error::storage(format!("set startup pragmas: {e}")))?;
Ok(conn)
}
fn wipe_solo_owned_files(data_dir: &Path) -> Result<()> {
if !data_dir.exists() {
return Ok(());
}
for name in SOLO_OWNED_FILES_ROOT {
let p = data_dir.join(name);
if p.is_file() {
std::fs::remove_file(&p)
.map_err(|e| Error::storage(format!("remove {}: {e}", p.display())))?;
}
}
let tenants = data_dir.join(TENANTS_SUBDIR);
if tenants.is_dir() {
for entry in std::fs::read_dir(&tenants)
.map_err(|e| Error::storage(format!("read tenants dir {}: {e}", tenants.display())))?
{
let entry = entry.map_err(|e| {
Error::storage(format!("scan tenants dir {}: {e}", tenants.display()))
})?;
let p = entry.path();
if p.is_file() {
std::fs::remove_file(&p)
.map_err(|e| Error::storage(format!("remove {}: {e}", p.display())))?;
}
}
let _ = std::fs::remove_dir(&tenants);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use crate::test_support::LLM_ENV_LOCK as ENV_LOCK;
struct LlmEnvGuard;
impl Drop for LlmEnvGuard {
fn drop(&mut self) {
for k in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] {
unsafe { std::env::remove_var(k) };
}
}
}
fn fresh_llm_env() -> LlmEnvGuard {
for k in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] {
unsafe { std::env::remove_var(k) };
}
LlmEnvGuard
}
fn fixture_params(dir: &Path) -> InitParams {
InitParams {
data_dir: dir.to_path_buf(),
passphrase: Zeroizing::new("correct horse battery staple".into()),
force: false,
embedder: default_embedder(),
}
}
#[test]
fn happy_path_creates_db_and_config() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
let outcome = init(fixture_params(&dir)).expect("init should succeed");
assert_eq!(outcome.data_dir, dir);
assert!(outcome.db_path.exists(), "default.db must exist");
assert!(outcome.config_path.exists(), "solo.config.toml must exist");
assert_eq!(outcome.schema_version, 10);
assert_eq!(outcome.db_path, dir.join("tenants").join("default.db"));
assert!(outcome.tenants_index_path.is_file());
assert_eq!(outcome.tenants_index_schema_version, 9);
assert!(!outcome.upgraded_from_v071);
assert!(!dir.join("solo.db").exists());
assert!(!dir.join("solo.lock").exists(), "lockfile must be removed");
}
#[test]
fn fresh_install_registers_default_tenant_active() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
let outcome = init(fixture_params(&dir)).unwrap();
let cfg = SoloConfig::read(&outcome.config_path).unwrap();
let salt = cfg.salt_bytes().unwrap();
let key = KeyMaterial::derive("correct horse battery staple", &salt).unwrap();
let idx = crate::tenants::TenantsIndex::open(&dir, &key).unwrap();
let listed = idx.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].tenant_id, TenantId::default_tenant());
assert_eq!(listed[0].status, crate::tenants::TenantStatus::Active);
assert_eq!(listed[0].db_filename, "default.db");
}
#[test]
fn config_round_trips_salt_correctly() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
let outcome = init(fixture_params(&dir)).unwrap();
let cfg = SoloConfig::read(&outcome.config_path).unwrap();
let salt = cfg.salt_bytes().unwrap();
let key = KeyMaterial::derive("correct horse battery staple", &salt).unwrap();
let conn = open_sqlcipher(&outcome.db_path, &key).unwrap();
let v: u32 = conn
.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(v, 10);
}
#[test]
#[ignore = "requires SQLCipher: under plain bundled SQLite, PRAGMA key is a no-op so wrong keys silently succeed. Run with the workspace's bundled-sqlcipher-vendored-openssl feature: `cargo test -p solo-storage -- --include-ignored`"]
fn wrong_passphrase_fails_to_open() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
let outcome = init(fixture_params(&dir)).unwrap();
let cfg = SoloConfig::read(&outcome.config_path).unwrap();
let salt = cfg.salt_bytes().unwrap();
let bad_key = KeyMaterial::derive("WRONG PASSPHRASE", &salt).unwrap();
let conn = open_sqlcipher(&outcome.db_path, &bad_key);
let conn = match conn {
Ok(c) => c,
Err(_) => return, };
let res: rusqlite::Result<u32> =
conn.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
row.get(0)
});
assert!(res.is_err(), "wrong passphrase must fail to read");
}
#[test]
fn second_init_without_force_refuses() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
init(fixture_params(&dir)).unwrap();
let err = init(fixture_params(&dir)).unwrap_err();
assert!(
matches!(err, Error::Conflict(_)),
"expected Conflict, got {err:?}"
);
assert!(err.to_string().contains("already initialized"));
}
#[test]
fn force_wipes_and_re_inits() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
let first = init(fixture_params(&dir)).unwrap();
let first_cfg = SoloConfig::read(&first.config_path).unwrap();
let mut params = fixture_params(&dir);
params.force = true;
let second = init(params).unwrap();
let second_cfg = SoloConfig::read(&second.config_path).unwrap();
assert_ne!(first_cfg.salt_hex, second_cfg.salt_hex);
}
#[test]
fn force_wipes_current_hnsw_snapshot_files() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
let _ = init(fixture_params(&dir)).unwrap();
let planted = [
"hnsw_episodes.hnsw.data",
"hnsw_episodes.hnsw.graph",
"hnsw_episodes_bak.hnsw.data",
"hnsw_episodes_bak.hnsw.graph",
"hnsw_episodes_tmp.hnsw.data",
"hnsw_episodes_tmp.hnsw.graph",
];
for name in &planted {
std::fs::write(dir.join(name), b"stale snapshot data").unwrap();
}
let mut params = fixture_params(&dir);
params.force = true;
let _ = init(params).unwrap();
for name in &planted {
let p = dir.join(name);
assert!(
!p.exists(),
"{} should have been wiped by --force",
p.display()
);
}
}
#[test]
fn empty_passphrase_rejected() {
let tmp = TempDir::new().unwrap();
let mut params = fixture_params(tmp.path());
params.passphrase.clear();
let err = init(params).unwrap_err();
assert!(matches!(err, Error::InvalidInput(_)), "got: {err:?}");
}
#[test]
fn init_upgrades_v071_install_in_place() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
std::fs::create_dir_all(&dir).unwrap();
let passphrase = "v071-upgrade-passphrase";
let salt = KeyMaterial::fresh_salt().unwrap();
let key = KeyMaterial::derive(passphrase, &salt).unwrap();
let cfg = SoloConfig::new(salt, default_embedder());
cfg.write(&dir.join("solo.config.toml")).unwrap();
let legacy_db = dir.join("solo.db");
let mut conn = open_sqlcipher(&legacy_db, &key).unwrap();
migration::run_migrations(&mut conn).unwrap();
drop(conn);
let outcome = init(InitParams {
data_dir: dir.clone(),
passphrase: Zeroizing::new(passphrase.into()),
force: false,
embedder: default_embedder(),
})
.unwrap();
assert!(outcome.upgraded_from_v071);
assert_eq!(outcome.db_path, dir.join("tenants").join("default.db"));
assert!(outcome.db_path.is_file());
assert!(outcome.tenants_index_path.is_file());
assert_eq!(outcome.tenants_index_schema_version, 9);
assert!(!legacy_db.exists());
assert_eq!(outcome.schema_version, 10);
}
#[test]
#[ignore = "requires SQLCipher: under plain bundled SQLite, PRAGMA key is a no-op so wrong keys silently succeed."]
fn init_v071_upgrade_with_wrong_passphrase_errors() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
std::fs::create_dir_all(&dir).unwrap();
let salt = KeyMaterial::fresh_salt().unwrap();
let key = KeyMaterial::derive("right-passphrase", &salt).unwrap();
let cfg = SoloConfig::new(salt, default_embedder());
cfg.write(&dir.join("solo.config.toml")).unwrap();
let legacy_db = dir.join("solo.db");
let mut conn = open_sqlcipher(&legacy_db, &key).unwrap();
migration::run_migrations(&mut conn).unwrap();
drop(conn);
let err = init(InitParams {
data_dir: dir,
passphrase: Zeroizing::new("wrong-passphrase".into()),
force: false,
embedder: default_embedder(),
})
.unwrap_err();
assert!(
matches!(err, Error::Storage(_)),
"wrong passphrase must surface as a Storage error, got {err:?}"
);
}
#[test]
fn init_writes_llm_none_when_no_env_key_present() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_llm_env();
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
let outcome = init(fixture_params(&dir)).expect("init should succeed");
let cfg = SoloConfig::read(&outcome.config_path).unwrap();
assert_eq!(
cfg.llm,
Some(LlmSettings::None),
"no ANTHROPIC_API_KEY in env → init writes [llm] mode = \"none\""
);
}
#[test]
fn init_writes_llm_anthropic_when_env_key_present() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_llm_env();
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-test-fixture") };
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("solo-data");
let outcome = init(fixture_params(&dir)).expect("init should succeed");
let cfg = SoloConfig::read(&outcome.config_path).unwrap();
match cfg.llm {
Some(LlmSettings::Anthropic {
ref api_key_env,
ref model,
}) => {
assert_eq!(api_key_env, "ANTHROPIC_API_KEY");
assert_eq!(model, "claude-sonnet-4-6");
}
other => panic!("expected Anthropic variant from env-detected default, got {other:?}"),
}
}
#[test]
fn default_llm_settings_from_env_picks_anthropic_when_set() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_llm_env();
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-fixture") };
match default_llm_settings_from_env() {
LlmSettings::Anthropic { api_key_env, .. } => {
assert_eq!(api_key_env, "ANTHROPIC_API_KEY");
}
other => panic!("expected Anthropic, got {other:?}"),
}
}
#[test]
fn default_llm_settings_from_env_returns_none_when_empty_value() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_llm_env();
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "") };
assert_eq!(default_llm_settings_from_env(), LlmSettings::None);
}
#[test]
fn default_llm_settings_from_env_returns_none_when_unset() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _g = fresh_llm_env();
assert_eq!(default_llm_settings_from_env(), LlmSettings::None);
}
#[test]
fn cloud_sync_path_rejected() {
let placeholder = std::env::temp_dir().join("solo-init-cloud-test");
let mut params = fixture_params(&placeholder);
#[cfg(windows)]
let cloud = std::path::PathBuf::from(r"C:\Users\x\Dropbox\solo");
#[cfg(not(windows))]
let cloud = std::path::PathBuf::from("/Users/x/Dropbox/solo");
params.data_dir = cloud;
let err = init(params).unwrap_err();
assert!(err.to_string().contains("cloud-sync"), "got: {err}");
}
}