use rusqlite::Connection;
use solo_core::{Embedder, Error, Result};
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
use crate::{
config::{EmbedderConfig, SoloConfig},
key_material::KeyMaterial,
lockfile::Lockfile,
migration,
path_validation::validate_data_dir,
};
pub fn default_data_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".solo"))
}
const SOLO_OWNED_FILES: &[&str] = &[
"solo.db",
"solo.db-wal",
"solo.db-shm",
"solo.config.toml",
"solo.config.toml.tmp",
"solo.lock",
"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",
];
#[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(),
}
}
#[derive(Debug)]
pub struct InitOutcome {
pub data_dir: PathBuf,
pub db_path: PathBuf,
pub config_path: PathBuf,
pub schema_version: u32,
}
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 db_path = data_dir.join("solo.db");
let config_path = data_dir.join("solo.config.toml");
let lock_path = data_dir.join("solo.lock");
let already_initialized = db_path.exists() || config_path.exists();
if already_initialized {
if !force {
return Err(Error::conflict(format!(
"data directory is already initialized: {}\n\
Re-run with --force to wipe and re-initialize \
(DESTRUCTIVE — all stored memories will be lost).",
data_dir.display()
)));
}
wipe_solo_owned_files(&data_dir)?;
}
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)?;
let salt = KeyMaterial::fresh_salt()?;
let key = KeyMaterial::derive(&passphrase, &salt)?;
let mut conn = open_sqlcipher(&db_path, &key)?;
let schema_version = migration::run_migrations(&mut conn)?;
drop(conn);
let conn2 = open_sqlcipher(&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: {e}")))?;
drop(conn2);
if highest != schema_version {
return Err(Error::storage(format!(
"cipher round-trip read drift: wrote {schema_version}, read {highest}"
)));
}
let cfg = SoloConfig::new(salt, embedder);
cfg.write(&config_path)?;
Ok(InitOutcome {
data_dir,
db_path,
config_path,
schema_version,
})
}
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 = {
let hex = key.as_hex();
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 {
let p = data_dir.join(name);
if p.exists() {
std::fs::remove_file(&p)
.map_err(|e| Error::storage(format!("remove {}: {e}", p.display())))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
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(), "solo.db must exist");
assert!(outcome.config_path.exists(), "solo.config.toml must exist");
assert_eq!(outcome.schema_version, 3);
assert!(!dir.join("solo.lock").exists(), "lockfile must be removed");
}
#[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, 3);
}
#[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 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}");
}
}