Skip to main content

cfgd_core/state/
backups.rs

1use rusqlite::params;
2
3use super::StateStore;
4use super::types::FileBackupRecord;
5use crate::errors::{Result, StateError};
6
7impl StateStore {
8    /// Store a file backup before overwriting.
9    pub fn store_file_backup(
10        &self,
11        apply_id: i64,
12        file_path: &str,
13        state: &crate::FileState,
14    ) -> Result<()> {
15        let timestamp = crate::utc_now_iso8601();
16        self.conn.execute(
17            "INSERT INTO file_backups (apply_id, file_path, content_hash, content, permissions, was_symlink, symlink_target, oversized, backed_up_at)
18             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
19            params![
20                apply_id,
21                file_path,
22                state.content_hash,
23                state.content,
24                state.permissions.map(|p| p as i64),
25                state.is_symlink as i64,
26                state.symlink_target.as_ref().map(|p| p.display().to_string()),
27                state.oversized as i64,
28                timestamp,
29            ],
30        )?;
31        Ok(())
32    }
33
34    /// Get a file backup by apply_id and path.
35    pub fn get_file_backup(
36        &self,
37        apply_id: i64,
38        file_path: &str,
39    ) -> Result<Option<FileBackupRecord>> {
40        let result = self.conn.query_row(
41            "SELECT id, apply_id, file_path, content_hash, content, permissions, was_symlink, symlink_target, oversized, backed_up_at
42             FROM file_backups WHERE apply_id = ?1 AND file_path = ?2",
43            params![apply_id, file_path],
44            |row| {
45                Ok(FileBackupRecord {
46                    id: row.get(0)?,
47                    apply_id: row.get(1)?,
48                    file_path: row.get(2)?,
49                    content_hash: row.get(3)?,
50                    content: row.get(4)?,
51                    permissions: row.get::<_, Option<i64>>(5)?.map(|p| p as u32),
52                    was_symlink: row.get::<_, i64>(6)? != 0,
53                    symlink_target: row.get(7)?,
54                    oversized: row.get::<_, i64>(8)? != 0,
55                    backed_up_at: row.get(9)?,
56                })
57            },
58        );
59
60        match result {
61            Ok(record) => Ok(Some(record)),
62            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
63            Err(e) => Err(StateError::Database(e.to_string()).into()),
64        }
65    }
66
67    /// Get all file backups for a specific apply (for full rollback).
68    pub fn get_apply_backups(&self, apply_id: i64) -> Result<Vec<FileBackupRecord>> {
69        let mut stmt = self.conn.prepare(
70            "SELECT id, apply_id, file_path, content_hash, content, permissions, was_symlink, symlink_target, oversized, backed_up_at
71             FROM file_backups WHERE apply_id = ?1 ORDER BY id",
72        )?;
73
74        let records = stmt
75            .query_map(params![apply_id], |row| {
76                Ok(FileBackupRecord {
77                    id: row.get(0)?,
78                    apply_id: row.get(1)?,
79                    file_path: row.get(2)?,
80                    content_hash: row.get(3)?,
81                    content: row.get(4)?,
82                    permissions: row.get::<_, Option<i64>>(5)?.map(|p| p as u32),
83                    was_symlink: row.get::<_, i64>(6)? != 0,
84                    symlink_target: row.get(7)?,
85                    oversized: row.get::<_, i64>(8)? != 0,
86                    backed_up_at: row.get(9)?,
87                })
88            })?
89            .collect::<std::result::Result<Vec<_>, _>>()?;
90
91        Ok(records)
92    }
93
94    /// Get the most recent backup for a file path (for restore after removal).
95    pub fn latest_backup_for_path(&self, file_path: &str) -> Result<Option<FileBackupRecord>> {
96        let result = self.conn.query_row(
97            "SELECT id, apply_id, file_path, content_hash, content, permissions, was_symlink, symlink_target, oversized, backed_up_at
98             FROM file_backups WHERE file_path = ?1 ORDER BY id DESC LIMIT 1",
99            params![file_path],
100            |row| {
101                Ok(FileBackupRecord {
102                    id: row.get(0)?,
103                    apply_id: row.get(1)?,
104                    file_path: row.get(2)?,
105                    content_hash: row.get(3)?,
106                    content: row.get(4)?,
107                    permissions: row.get::<_, Option<i64>>(5)?.map(|p| p as u32),
108                    was_symlink: row.get::<_, i64>(6)? != 0,
109                    symlink_target: row.get(7)?,
110                    oversized: row.get::<_, i64>(8)? != 0,
111                    backed_up_at: row.get(9)?,
112                })
113            },
114        );
115
116        match result {
117            Ok(record) => Ok(Some(record)),
118            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
119            Err(e) => Err(StateError::Database(e.to_string()).into()),
120        }
121    }
122
123    /// Get the earliest file backup for each unique file path from applies after the given ID.
124    /// This captures the state that existed right after the target apply completed, for each
125    /// file that was subsequently modified. Used by rollback to restore to a prior apply's state.
126    pub fn file_backups_after_apply(&self, after_apply_id: i64) -> Result<Vec<FileBackupRecord>> {
127        // For each distinct file_path in backups from applies after `after_apply_id`,
128        // pick the backup with the smallest apply_id (earliest apply after target).
129        let mut stmt = self.conn.prepare(
130            "SELECT b.id, b.apply_id, b.file_path, b.content_hash, b.content, b.permissions,
131                    b.was_symlink, b.symlink_target, b.oversized, b.backed_up_at
132             FROM file_backups b
133             INNER JOIN (
134                 SELECT file_path, MIN(apply_id) AS min_apply_id
135                 FROM file_backups
136                 WHERE apply_id > ?1
137                 GROUP BY file_path
138             ) earliest ON b.file_path = earliest.file_path AND b.apply_id = earliest.min_apply_id
139             ORDER BY b.id",
140        )?;
141
142        let records = stmt
143            .query_map(params![after_apply_id], |row| {
144                Ok(FileBackupRecord {
145                    id: row.get(0)?,
146                    apply_id: row.get(1)?,
147                    file_path: row.get(2)?,
148                    content_hash: row.get(3)?,
149                    content: row.get(4)?,
150                    permissions: row.get::<_, Option<i64>>(5)?.map(|p| p as u32),
151                    was_symlink: row.get::<_, i64>(6)? != 0,
152                    symlink_target: row.get(7)?,
153                    oversized: row.get::<_, i64>(8)? != 0,
154                    backed_up_at: row.get(9)?,
155                })
156            })?
157            .collect::<std::result::Result<Vec<_>, _>>()?;
158
159        Ok(records)
160    }
161
162    /// Prune old backups, keeping only the last N applies' worth.
163    pub fn prune_old_backups(&self, keep_last_n: usize) -> Result<usize> {
164        let deleted: usize = self.conn.execute(
165            "DELETE FROM file_backups WHERE apply_id NOT IN (
166                SELECT id FROM applies ORDER BY id DESC LIMIT ?1
167            )",
168            params![keep_last_n as i64],
169        )?;
170        Ok(deleted)
171    }
172}