use std::collections::HashMap;
use rusqlite::{Connection, Transaction, params, params_from_iter};
use crate::error::{Error, Result};
use crate::types::{EnvEntry, ProjectSummary, SaveMetadata};
pub fn get_config(conn: &Connection, key: &str) -> Result<Option<String>> {
let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?1")?;
let mut rows = stmt.query(params![key])?;
match rows.next()? {
Some(row) => Ok(Some(row.get(0)?)),
None => Ok(None),
}
}
pub fn get_configs(conn: &Connection, keys: &[&str]) -> Result<HashMap<String, String>> {
if keys.is_empty() {
return Ok(HashMap::new());
}
let placeholders = vec!["?"; keys.len()].join(", ");
let sql = format!("SELECT key, value FROM config WHERE key IN ({placeholders})");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_from_iter(keys.iter()), |row| {
let k: String = row.get(0)?;
let v: String = row.get(1)?;
Ok((k, v))
})?;
let mut out = HashMap::with_capacity(keys.len());
for row in rows {
let (k, v) = row?;
out.insert(k, v);
}
Ok(out)
}
pub fn set_config(conn: &Connection, key: &str, value: &str) -> Result<()> {
conn.execute(
"INSERT INTO config (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
Ok(())
}
fn format_hmac_data(
project_path: &str,
file_path: &str,
branch: &str,
commit_hash: &str,
timestamp: &str,
content_hash: &str,
) -> String {
let fields = [
project_path,
file_path,
branch,
commit_hash,
timestamp,
content_hash,
];
fields
.iter()
.map(|f| format!("{}:{f}", f.len()))
.collect::<Vec<_>>()
.join("|")
}
pub fn verify_save_hmac(save: &SaveMetadata, key: &[u8; 32]) -> Result<()> {
if save.hmac.is_empty() {
return Err(Error::HmacMismatch);
}
let data = format_hmac_data(
&save.project_path,
&save.file_path,
&save.branch,
&save.commit_hash,
&save.timestamp,
&save.content_hash,
);
if !crate::crypto::hmac::verify_hmac(key, data.as_bytes(), &save.hmac)? {
return Err(Error::HmacMismatch);
}
Ok(())
}
pub struct SaveInput<'a> {
pub project_path: &'a str,
pub file_path: &'a str,
pub branch: &'a str,
pub commit_hash: &'a str,
pub timestamp: &'a str,
pub content_hash: &'a str,
pub entries: &'a [EnvEntry],
pub aes_key: Option<&'a [u8; 32]>,
pub message: Option<&'a str>,
}
#[allow(clippy::too_many_arguments)]
pub fn insert_save(
conn: &mut Connection,
project_path: &str,
file_path: &str,
branch: &str,
commit_hash: &str,
timestamp: &str,
content_hash: &str,
entries: &[EnvEntry],
aes_key: Option<&[u8; 32]>,
) -> Result<i64> {
insert_save_input(
conn,
&SaveInput {
project_path,
file_path,
branch,
commit_hash,
timestamp,
content_hash,
entries,
aes_key,
message: None,
},
)
}
#[allow(clippy::too_many_arguments)]
pub fn insert_save_with_message(
conn: &mut Connection,
project_path: &str,
file_path: &str,
branch: &str,
commit_hash: &str,
timestamp: &str,
content_hash: &str,
entries: &[EnvEntry],
aes_key: Option<&[u8; 32]>,
message: Option<&str>,
) -> Result<i64> {
insert_save_input(
conn,
&SaveInput {
project_path,
file_path,
branch,
commit_hash,
timestamp,
content_hash,
entries,
aes_key,
message,
},
)
}
pub fn insert_save_input(conn: &mut Connection, input: &SaveInput<'_>) -> Result<i64> {
let tx = conn.transaction()?;
let save_id = insert_save_into_tx(&tx, input)?;
tx.commit()?;
Ok(save_id)
}
fn insert_save_into_tx(tx: &Transaction<'_>, input: &SaveInput<'_>) -> Result<i64> {
let hmac_value = if let Some(key) = input.aes_key {
let data = format_hmac_data(
input.project_path,
input.file_path,
input.branch,
input.commit_hash,
input.timestamp,
input.content_hash,
);
crate::crypto::hmac::compute_hmac(key, data.as_bytes())?
} else {
String::new()
};
let message_value = input.message.unwrap_or("");
tx.execute(
"INSERT INTO saves (project_path, file_path, branch, commit_hash, timestamp, content_hash, hmac, message)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
input.project_path, input.file_path, input.branch,
input.commit_hash, input.timestamp, input.content_hash, hmac_value,
message_value
],
)?;
let save_id = tx.last_insert_rowid();
let mut stmt =
tx.prepare("INSERT INTO entries (save_id, key, value, comment) VALUES (?1, ?2, ?3, ?4)")?;
let cipher = match input.aes_key {
Some(key) => Some(crate::crypto::aes::build_cipher(key)?),
None => None,
};
for entry in input.entries {
let comment_str = entry.comment.as_deref().unwrap_or("");
if let Some(c) = &cipher {
let enc_value = crate::crypto::aes::encrypt_with_cipher(c, entry.value.as_bytes())?;
let enc_comment = crate::crypto::aes::encrypt_with_cipher(c, comment_str.as_bytes())?;
stmt.execute(params![save_id, entry.key, enc_value, enc_comment])?;
} else {
stmt.execute(params![save_id, entry.key, entry.value, comment_str])?;
}
}
Ok(save_id)
}
fn row_to_save_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<SaveMetadata> {
let message_raw: String = row.get(8)?;
Ok(SaveMetadata {
id: row.get(0)?,
project_path: row.get(1)?,
file_path: row.get(2)?,
branch: row.get(3)?,
commit_hash: row.get(4)?,
timestamp: row.get(5)?,
content_hash: row.get(6)?,
hmac: row.get(7)?,
message: if message_raw.is_empty() {
None
} else {
Some(message_raw)
},
})
}
fn read_bytes_from_row(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result<Vec<u8>> {
let val = row.get_ref(idx)?;
Ok(match val {
rusqlite::types::ValueRef::Text(b) => b.to_vec(),
rusqlite::types::ValueRef::Blob(b) => b.to_vec(),
rusqlite::types::ValueRef::Null => Vec::new(),
_ => Vec::new(),
})
}
#[cfg(test)]
pub(crate) fn tests_read_bytes(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result<Vec<u8>> {
read_bytes_from_row(row, idx)
}
const SAVE_COLUMNS: &str =
"id, project_path, file_path, branch, commit_hash, timestamp, content_hash, hmac, message";
pub fn list_saves(
conn: &Connection,
project_path: &str,
branch: Option<&str>,
commit: Option<&str>,
max: usize,
filter: Option<&str>,
) -> Result<Vec<SaveMetadata>> {
let mut sql = format!("SELECT {SAVE_COLUMNS} FROM saves WHERE project_path = ?1",);
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> =
vec![Box::new(project_path.to_string())];
let mut idx = 2;
if let Some(b) = branch {
sql.push_str(&format!(" AND branch = ?{idx}"));
param_values.push(Box::new(b.to_string()));
idx += 1;
}
if let Some(c) = commit {
sql.push_str(&format!(" AND commit_hash = ?{idx}"));
param_values.push(Box::new(c.to_string()));
idx += 1;
}
if let Some(f) = filter {
sql.push_str(&format!(" AND file_path LIKE ?{idx}"));
let pattern = if f.contains('*') || f.contains('/') {
f.replace('*', "%")
} else {
format!("%{f}")
};
param_values.push(Box::new(pattern));
}
sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT {max}"));
let mut stmt = conn.prepare(&sql)?;
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_ref.as_slice(), row_to_save_metadata)?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}
pub fn list_saves_history(
conn: &Connection,
project_path: &str,
exclude_branch: &str,
max: usize,
) -> Result<Vec<SaveMetadata>> {
let sql = format!(
"SELECT {SAVE_COLUMNS} FROM saves
WHERE project_path = ?1 AND branch != ?2
ORDER BY timestamp DESC
LIMIT ?3",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(
params![
project_path,
exclude_branch,
i64::try_from(max).unwrap_or(i64::MAX)
],
row_to_save_metadata,
)?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}
pub fn get_save_entries(
conn: &Connection,
save_id: i64,
aes_key: Option<&[u8; 32]>,
) -> Result<Vec<EnvEntry>> {
let mut stmt =
conn.prepare("SELECT key, value, comment FROM entries WHERE save_id = ?1 ORDER BY id")?;
let raw_rows: Vec<(String, Vec<u8>, Vec<u8>)> = {
let rows = stmt.query_map(params![save_id], |row| {
let key: String = row.get(0)?;
let value_bytes = read_bytes_from_row(row, 1)?;
let comment_bytes = read_bytes_from_row(row, 2)?;
Ok((key, value_bytes, comment_bytes))
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
result
};
let cipher = match aes_key {
Some(k) => Some(crate::crypto::aes::build_cipher(k)?),
None => None,
};
let mut results = Vec::new();
for (key, value_bytes, comment_bytes) in raw_rows {
let (value, comment_str) = if let Some(c) = &cipher {
let v = crate::crypto::aes::decrypt_with_cipher(c, &value_bytes)?;
let cmt = crate::crypto::aes::decrypt_with_cipher(c, &comment_bytes)?;
(
String::from_utf8(v)
.map_err(|e| Error::Decryption(format!("invalid UTF-8 in value: {e}")))?,
String::from_utf8(cmt)
.map_err(|e| Error::Decryption(format!("invalid UTF-8 in comment: {e}")))?,
)
} else {
(
String::from_utf8(value_bytes)
.map_err(|e| Error::Other(format!("invalid UTF-8 in value: {e}")))?,
String::from_utf8(comment_bytes)
.map_err(|e| Error::Other(format!("invalid UTF-8 in comment: {e}")))?,
)
};
results.push(EnvEntry {
key,
value,
comment: if comment_str.is_empty() {
None
} else {
Some(comment_str)
},
});
}
Ok(results)
}
pub fn get_save_by_hash(
conn: &Connection,
project_path: &str,
hash: &str,
) -> Result<Option<SaveMetadata>> {
let sql = format!(
"SELECT {SAVE_COLUMNS} FROM saves
WHERE project_path = ?1 AND content_hash LIKE ?2
ORDER BY timestamp DESC
LIMIT 1",
);
let mut stmt = conn.prepare(&sql)?;
let pattern = format!("{hash}%");
let mut rows = stmt.query(params![project_path, pattern])?;
match rows.next()? {
Some(row) => Ok(Some(row_to_save_metadata(row)?)),
None => Ok(None),
}
}
pub fn delete_save(conn: &Connection, save_id: i64) -> Result<()> {
conn.execute("DELETE FROM entries WHERE save_id = ?1", params![save_id])?;
let count = conn.execute("DELETE FROM saves WHERE id = ?1", params![save_id])?;
if count == 0 {
return Err(Error::SaveNotFound(save_id.to_string()));
}
Ok(())
}
pub fn delete_saves_by_branch(
conn: &mut Connection,
project_path: &str,
branch: &str,
) -> Result<usize> {
let tx = conn.transaction()?;
tx.execute(
"DELETE FROM entries WHERE save_id IN
(SELECT id FROM saves WHERE project_path = ?1 AND branch = ?2)",
params![project_path, branch],
)?;
let count = tx.execute(
"DELETE FROM saves WHERE project_path = ?1 AND branch = ?2",
params![project_path, branch],
)?;
tx.commit()?;
Ok(count)
}
pub fn delete_saves_by_project(conn: &mut Connection, project_path: &str) -> Result<usize> {
let tx = conn.transaction()?;
tx.execute(
"DELETE FROM entries WHERE save_id IN
(SELECT id FROM saves WHERE project_path = ?1)",
params![project_path],
)?;
let count = tx.execute(
"DELETE FROM saves WHERE project_path = ?1",
params![project_path],
)?;
tx.commit()?;
Ok(count)
}
pub fn list_projects(conn: &Connection) -> Result<Vec<ProjectSummary>> {
let mut stmt = conn.prepare(
"SELECT project_path, COUNT(*) as cnt, MAX(timestamp) as last_ts
FROM saves
GROUP BY project_path
ORDER BY last_ts DESC",
)?;
let rows = stmt.query_map([], |row| {
Ok(ProjectSummary {
project_path: row.get(0)?,
save_count: row.get(1)?,
last_save: row.get(2)?,
})
})?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}
pub fn for_each_save(
conn: &Connection,
aes_key: Option<&[u8; 32]>,
mut f: impl FnMut(SaveMetadata, Vec<EnvEntry>) -> Result<()>,
) -> Result<()> {
let saves = {
let sql = format!("SELECT {SAVE_COLUMNS} FROM saves ORDER BY timestamp");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], row_to_save_metadata)?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
results
};
for save in saves {
let entries = get_save_entries(conn, save.id, aes_key)?;
f(save, entries)?;
}
Ok(())
}
pub fn get_all_saves(
conn: &Connection,
aes_key: Option<&[u8; 32]>,
) -> Result<Vec<(SaveMetadata, Vec<EnvEntry>)>> {
let mut out = Vec::new();
for_each_save(conn, aes_key, |save, entries| {
out.push((save, entries));
Ok(())
})?;
Ok(out)
}
fn has_save_with_hash(conn: &Connection, project_path: &str, content_hash: &str) -> Result<bool> {
let mut stmt =
conn.prepare("SELECT 1 FROM saves WHERE project_path = ?1 AND content_hash = ?2 LIMIT 1")?;
let exists = stmt.exists(params![project_path, content_hash])?;
Ok(exists)
}
pub fn insert_all_saves(
conn: &mut Connection,
saves: &[crate::export::DumpSave],
aes_key: Option<&[u8; 32]>,
) -> Result<(usize, usize)> {
let tx = conn.transaction()?;
let mut inserted = 0;
let mut skipped = 0;
for save in saves {
if has_save_with_hash(&tx, &save.project_path, &save.content_hash)? {
skipped += 1;
continue;
}
let entries: Vec<EnvEntry> = save.entries.iter().map(EnvEntry::from).collect();
insert_save_into_tx(
&tx,
&SaveInput {
project_path: &save.project_path,
file_path: &save.file,
branch: &save.branch,
commit_hash: &save.commit,
timestamp: &save.timestamp,
content_hash: &save.content_hash,
entries: &entries,
aes_key,
message: save.message.as_deref(),
},
)?;
inserted += 1;
}
tx.commit()?;
Ok((inserted, skipped))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hmac_data_length_prefixed() {
let a = format_hmac_data("a|b", "c", "", "", "", "");
let b = format_hmac_data("a", "b|c", "", "", "", "");
assert_ne!(
a, b,
"Length-prefixed HMAC data should distinguish fields with | in values"
);
}
#[test]
fn hmac_data_deterministic() {
let a = format_hmac_data("/proj", ".env", "main", "abc", "2024-01-01", "h1");
let b = format_hmac_data("/proj", ".env", "main", "abc", "2024-01-01", "h1");
assert_eq!(a, b);
}
#[test]
fn hmac_data_format() {
let data = format_hmac_data("ab", "c", "d", "ef", "g", "hi");
assert_eq!(data, "2:ab|1:c|1:d|2:ef|1:g|2:hi");
}
#[test]
fn insert_save_with_message_stores_and_retrieves() {
let mut conn = crate::test_helpers::test_conn();
let entries = crate::test_helpers::sample_entries();
let id = insert_save_with_message(
&mut conn,
"/proj",
".env",
"main",
"abc",
"2024-01-01T00:00:00Z",
"h1",
&entries,
None,
Some("trying new DB config"),
)
.unwrap();
assert!(id > 0);
let saves = list_saves(&conn, "/proj", None, None, 10, None).unwrap();
assert_eq!(saves.len(), 1);
assert_eq!(saves[0].message.as_deref(), Some("trying new DB config"));
}
#[test]
fn insert_save_without_message_returns_none() {
let mut conn = crate::test_helpers::test_conn();
let entries = crate::test_helpers::sample_entries();
insert_save(
&mut conn,
"/proj",
".env",
"main",
"abc",
"2024-01-01T00:00:00Z",
"h1",
&entries,
None,
)
.unwrap();
let saves = list_saves(&conn, "/proj", None, None, 10, None).unwrap();
assert_eq!(saves.len(), 1);
assert_eq!(saves[0].message, None);
}
#[test]
fn get_configs_batches_lookup() {
let conn = crate::test_helpers::test_conn();
set_config(&conn, "encryption_mode", "password").unwrap();
set_config(&conn, "key_file", "/tmp/key").unwrap();
set_config(&conn, "version", "1").unwrap();
let out = get_configs(&conn, &["encryption_mode", "key_file", "missing"]).unwrap();
assert_eq!(
out.get("encryption_mode").map(String::as_str),
Some("password")
);
assert_eq!(out.get("key_file").map(String::as_str), Some("/tmp/key"));
assert!(!out.contains_key("missing"));
assert!(!out.contains_key("version"));
}
#[test]
fn get_configs_empty_keys() {
let conn = crate::test_helpers::test_conn();
let out = get_configs(&conn, &[]).unwrap();
assert!(out.is_empty());
}
}