use std::collections::HashSet; use anyhow::Result; use serde_json::Value; use sqlx::PgPool; use uuid::Uuid; use crate::db; use crate::error::AppError; use crate::models::EntryWriteRow; #[derive(Debug, serde::Serialize)] pub struct RollbackResult { pub name: String, pub folder: String, #[serde(rename = "type")] pub entry_type: String, pub restored_version: i64, } /// Roll back entry `name` to `to_version` (or the most recent snapshot if None). pub async fn run( pool: &PgPool, entry_id: Uuid, to_version: Option, user_id: Option, ) -> Result { #[derive(sqlx::FromRow)] struct EntryHistoryRow { folder: String, #[sqlx(rename = "type")] entry_type: String, version: i64, action: String, tags: Vec, metadata: Value, } let live_entry: Option = if let Some(uid) = user_id { sqlx::query_as( "SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \ WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL", ) .bind(entry_id) .bind(uid) .fetch_optional(pool) .await? } else { sqlx::query_as( "SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \ WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL", ) .bind(entry_id) .fetch_optional(pool) .await? }; let live_entry = live_entry.ok_or(AppError::NotFoundEntry)?; let snap: Option = if let Some(ver) = to_version { sqlx::query_as( "SELECT folder, type, version, action, tags, metadata \ FROM entries_history \ WHERE entry_id = $1 AND version = $2 ORDER BY id ASC LIMIT 1", ) .bind(entry_id) .bind(ver) .fetch_optional(pool) .await? } else { sqlx::query_as( "SELECT folder, type, version, action, tags, metadata \ FROM entries_history \ WHERE entry_id = $1 ORDER BY id DESC LIMIT 1", ) .bind(entry_id) .fetch_optional(pool) .await? }; let snap = snap.ok_or_else(|| { anyhow::anyhow!( "No history found for entry '{}'{}.", live_entry.name, to_version .map(|v| format!(" at version {}", v)) .unwrap_or_default() ) })?; let snap_secret_snapshot = db::entry_secret_snapshot_from_metadata(&snap.metadata); let snap_metadata = db::strip_secret_snapshot_from_metadata(&snap.metadata); let mut tx = pool.begin().await?; let live: Option = sqlx::query_as( "SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \ WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", ) .bind(entry_id) .fetch_optional(&mut *tx) .await?; let live_entry_id = if let Some(ref lr) = live { let history_metadata = match db::metadata_with_secret_snapshot(&mut tx, lr.id, &lr.metadata).await { Ok(v) => v, Err(e) => { tracing::warn!(error = %e, "failed to build secret snapshot for entry history"); lr.metadata.clone() } }; if let Err(e) = db::snapshot_entry_history( &mut tx, db::EntrySnapshotParams { entry_id: lr.id, user_id, folder: &lr.folder, entry_type: &lr.entry_type, name: &lr.name, version: lr.version, action: "rollback", tags: &lr.tags, metadata: &history_metadata, }, ) .await { tracing::warn!(error = %e, "failed to snapshot entry before rollback"); } #[derive(sqlx::FromRow)] struct LiveField { id: Uuid, name: String, encrypted: Vec, } let live_fields: Vec = sqlx::query_as( "SELECT s.id, s.name, s.encrypted \ FROM entry_secrets es \ JOIN secrets s ON s.id = es.secret_id \ WHERE es.entry_id = $1", ) .bind(lr.id) .fetch_all(&mut *tx) .await?; for f in &live_fields { if let Err(e) = db::snapshot_secret_history( &mut tx, db::SecretSnapshotParams { secret_id: f.id, name: &f.name, encrypted: &f.encrypted, action: "rollback", }, ) .await { tracing::warn!(error = %e, "failed to snapshot secret field before rollback"); } } sqlx::query( "UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \ version = version + 1, updated_at = NOW() WHERE id = $7", ) .bind(&snap.folder) .bind(&snap.entry_type) .bind(&live_entry.name) .bind(&live_entry.notes) .bind(&snap.tags) .bind(&snap_metadata) .bind(lr.id) .execute(&mut *tx) .await?; lr.id } else { return Err(AppError::NotFoundEntry.into()); }; if let Some(secret_snapshot) = snap_secret_snapshot { restore_entry_secrets(&mut tx, live_entry_id, user_id, &secret_snapshot).await?; } crate::audit::log_tx( &mut tx, user_id, "rollback", &snap.folder, &snap.entry_type, &live_entry.name, serde_json::json!({ "entry_id": entry_id, "restored_version": snap.version, "original_action": snap.action, }), ) .await; tx.commit().await?; Ok(RollbackResult { name: live_entry.name, folder: snap.folder, entry_type: snap.entry_type, restored_version: snap.version, }) } async fn restore_entry_secrets( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, entry_id: Uuid, user_id: Option, snapshot: &[db::EntrySecretSnapshot], ) -> Result<()> { #[derive(sqlx::FromRow)] struct LinkedSecret { id: Uuid, name: String, encrypted: Vec, } let linked: Vec = sqlx::query_as( "SELECT s.id, s.name, s.encrypted \ FROM entry_secrets es \ JOIN secrets s ON s.id = es.secret_id \ WHERE es.entry_id = $1", ) .bind(entry_id) .fetch_all(&mut **tx) .await?; let target_names: HashSet<&str> = snapshot.iter().map(|s| s.name.as_str()).collect(); for s in &linked { if target_names.contains(s.name.as_str()) { continue; } if let Err(e) = db::snapshot_secret_history( tx, db::SecretSnapshotParams { secret_id: s.id, name: &s.name, encrypted: &s.encrypted, action: "rollback", }, ) .await { tracing::warn!(error = %e, "failed to snapshot secret before rollback unlink"); } sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2") .bind(entry_id) .bind(s.id) .execute(&mut **tx) .await?; sqlx::query( "DELETE FROM secrets s \ WHERE s.id = $1 \ AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", ) .bind(s.id) .execute(&mut **tx) .await?; } for snap in snapshot { let encrypted = ::hex::decode(&snap.encrypted_hex).map_err(|e| { anyhow::anyhow!("invalid secret snapshot data for '{}': {}", snap.name, e) })?; #[derive(sqlx::FromRow)] struct ExistingSecret { id: Uuid, encrypted: Vec, } let existing: Option = if let Some(uid) = user_id { sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id = $1 AND name = $2") .bind(uid) .bind(&snap.name) .fetch_optional(&mut **tx) .await? } else { sqlx::query_as("SELECT id, encrypted FROM secrets WHERE user_id IS NULL AND name = $1") .bind(&snap.name) .fetch_optional(&mut **tx) .await? }; let secret_id = if let Some(ex) = existing { if ex.encrypted != encrypted && let Err(e) = db::snapshot_secret_history( tx, db::SecretSnapshotParams { secret_id: ex.id, name: &snap.name, encrypted: &ex.encrypted, action: "rollback", }, ) .await { tracing::warn!(error = %e, "failed to snapshot secret before rollback restore"); } sqlx::query( "UPDATE secrets SET type = $1, encrypted = $2, version = version + 1, updated_at = NOW() \ WHERE id = $3", ) .bind(&snap.secret_type) .bind(&encrypted) .bind(ex.id) .execute(&mut **tx) .await?; ex.id } else if let Some(uid) = user_id { sqlx::query_scalar( "INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id", ) .bind(uid) .bind(&snap.name) .bind(&snap.secret_type) .bind(&encrypted) .fetch_one(&mut **tx) .await? } else { sqlx::query_scalar( "INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, $2, $3) RETURNING id", ) .bind(&snap.name) .bind(&snap.secret_type) .bind(&encrypted) .fetch_one(&mut **tx) .await? }; sqlx::query( "INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", ) .bind(entry_id) .bind(secret_id) .execute(&mut **tx) .await?; } Ok(()) }