Skip to main content

modde_core/stock/
mod.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use tracing::{info, warn};
5use xxhash_rust::xxh3::xxh3_64;
6
7use crate::db::ModdeDb;
8use crate::error::{CoreError, Result};
9use crate::hash::hash_file_xxhash;
10use crate::resolver::GameId;
11
12const TREE_HASH_FILENAME: &str = ".modde-tree-hash.toml";
13
14/// Manages vanilla game snapshots for stock game preservation.
15pub struct StockGameManager {
16    store_dir: PathBuf,
17    db: Option<ModdeDb>,
18}
19
20/// A snapshot of a vanilla game installation.
21#[derive(Debug)]
22pub struct StockSnapshot {
23    pub game_id: GameId,
24    pub path: PathBuf,
25    pub hash: String,
26}
27
28/// Persisted tree hash metadata stored alongside a snapshot.
29#[derive(Debug, Serialize, Deserialize)]
30struct TreeHashMeta {
31    /// Hex-encoded xxh3-64 hash of the sorted path+hash pairs.
32    tree_hash: String,
33    /// Number of files included in the hash.
34    file_count: usize,
35}
36
37impl StockGameManager {
38    #[must_use]
39    pub fn new(store_dir: PathBuf) -> Self {
40        Self {
41            store_dir,
42            db: None,
43        }
44    }
45
46    /// Create a manager backed by both filesystem and `SQLite`.
47    pub fn with_db(store_dir: PathBuf, db: ModdeDb) -> Self {
48        Self {
49            store_dir,
50            db: Some(db),
51        }
52    }
53
54    /// Default store directory: `~/.local/share/modde/stock/`.
55    #[must_use]
56    pub fn default_dir() -> PathBuf {
57        crate::paths::stock_dir()
58    }
59
60    /// Detect Steam install path for a given game.
61    pub fn detect_steam_install(&self, game_dir_name: &str) -> Option<PathBuf> {
62        let game_path = crate::paths::steam_common().join(game_dir_name);
63        game_path.exists().then_some(game_path)
64    }
65
66    /// Create a hardlink snapshot of a game installation.
67    ///
68    /// Falls back to file copy if source and destination are on different filesystems.
69    pub async fn snapshot(&self, game_id: &GameId, source_dir: &Path) -> Result<StockSnapshot> {
70        if !source_dir.exists() {
71            return Err(CoreError::GameNotDetected(game_id.to_string()));
72        }
73
74        let snapshot_dir = self.store_dir.join(game_id.as_str());
75        tokio::fs::create_dir_all(&snapshot_dir).await?;
76
77        // Walk source and hardlink/copy files
78        snapshot_recursive(source_dir, &snapshot_dir).await?;
79
80        // Compute and store the tree hash (dual-write: file + DB)
81        let tree_hash = compute_tree_hash(&snapshot_dir).await?;
82        store_tree_hash(&snapshot_dir, &tree_hash).await?;
83
84        if let Some(ref db) = self.db {
85            db.upsert_snapshot(
86                game_id,
87                &snapshot_dir,
88                &tree_hash.tree_hash,
89                tree_hash.file_count,
90            )
91            .await?;
92        }
93
94        info!(game_id = %game_id, path = %snapshot_dir.display(), hash = %tree_hash.tree_hash, "stock snapshot created");
95
96        Ok(StockSnapshot {
97            game_id: game_id.clone(),
98            path: snapshot_dir,
99            hash: tree_hash.tree_hash,
100        })
101    }
102
103    /// Verify an existing snapshot still matches the stored tree hash.
104    pub async fn verify(&self, game_id: &GameId) -> Result<bool> {
105        let snapshot_dir = self.store_dir.join(game_id.as_str());
106        if !snapshot_dir.exists() {
107            return Err(CoreError::Other(
108                format!("no snapshot found for game '{game_id}'").into(),
109            ));
110        }
111
112        let stored = load_tree_hash(&snapshot_dir).await?;
113        let current = compute_tree_hash(&snapshot_dir).await?;
114
115        if stored.tree_hash == current.tree_hash {
116            info!(game_id = %game_id, hash = %stored.tree_hash, "snapshot verified OK");
117            Ok(true)
118        } else {
119            warn!(
120                game_id = %game_id,
121                expected = %stored.tree_hash,
122                actual = %current.tree_hash,
123                "snapshot verification failed: tree hash mismatch"
124            );
125            Ok(false)
126        }
127    }
128}
129
130/// Walk a directory recursively, collecting `(relative_path, xxhash)` for every file.
131async fn collect_file_hashes(
132    root: &Path,
133    dir: &Path,
134    entries: &mut Vec<(String, u64)>,
135) -> Result<()> {
136    let mut read_dir = tokio::fs::read_dir(dir).await?;
137    while let Some(entry) = read_dir.next_entry().await? {
138        let path = entry.path();
139        let file_type = entry.file_type().await?;
140
141        // Skip the metadata file itself
142        if path.file_name().and_then(|n| n.to_str()) == Some(TREE_HASH_FILENAME) {
143            continue;
144        }
145
146        if file_type.is_dir() {
147            Box::pin(collect_file_hashes(root, &path, entries)).await?;
148        } else if file_type.is_file() {
149            let rel = path
150                .strip_prefix(root)
151                .unwrap_or(&path)
152                .to_string_lossy()
153                .replace('\\', "/");
154            let hash = hash_file_xxhash(&path).await?;
155            entries.push((rel, hash));
156        }
157    }
158    Ok(())
159}
160
161/// Compute a deterministic tree hash for an entire directory.
162///
163/// The hash is the xxh3-64 of all `"<relative_path>\0<hash_hex>\n"` pairs,
164/// sorted by relative path for determinism.
165async fn compute_tree_hash(dir: &Path) -> Result<TreeHashMeta> {
166    let mut entries = Vec::new();
167    collect_file_hashes(dir, dir, &mut entries).await?;
168
169    // Sort by relative path for deterministic ordering
170    entries.sort_by(|a, b| a.0.cmp(&b.0));
171
172    // Build the concatenated representation and hash it
173    let mut combined = Vec::new();
174    for (rel_path, hash) in &entries {
175        combined.extend_from_slice(rel_path.as_bytes());
176        combined.push(0);
177        combined.extend_from_slice(format!("{hash:016x}").as_bytes());
178        combined.push(b'\n');
179    }
180
181    let tree_hash = xxh3_64(&combined);
182
183    Ok(TreeHashMeta {
184        tree_hash: format!("{tree_hash:016x}"),
185        file_count: entries.len(),
186    })
187}
188
189/// Write tree hash metadata to the snapshot directory.
190async fn store_tree_hash(snapshot_dir: &Path, meta: &TreeHashMeta) -> Result<()> {
191    let meta_path = snapshot_dir.join(TREE_HASH_FILENAME);
192    let toml_str = toml::to_string_pretty(meta)?;
193    tokio::fs::write(&meta_path, toml_str.as_bytes()).await?;
194    Ok(())
195}
196
197/// Load tree hash metadata from the snapshot directory.
198async fn load_tree_hash(snapshot_dir: &Path) -> Result<TreeHashMeta> {
199    let meta_path = snapshot_dir.join(TREE_HASH_FILENAME);
200    let data = tokio::fs::read_to_string(&meta_path).await.map_err(|_| {
201        CoreError::Other(format!("tree hash metadata not found at {}", meta_path.display()).into())
202    })?;
203    let meta: TreeHashMeta = toml::from_str(&data)?;
204    Ok(meta)
205}
206
207async fn snapshot_recursive(src: &Path, dst: &Path) -> Result<()> {
208    let mut entries = tokio::fs::read_dir(src).await?;
209    while let Some(entry) = entries.next_entry().await? {
210        let file_type = entry.file_type().await?;
211        let src_path = entry.path();
212        let dst_path = dst.join(entry.file_name());
213
214        if file_type.is_dir() {
215            tokio::fs::create_dir_all(&dst_path).await?;
216            Box::pin(snapshot_recursive(&src_path, &dst_path)).await?;
217        } else if file_type.is_file() {
218            let kind = crate::link::link_or_copy(&src_path, &dst_path).await?;
219            tracing::debug!(src = %src_path.display(), dst = %dst_path.display(), ?kind, "stock snapshot linked file");
220        }
221    }
222    Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::io::Write;
229    use tempfile::TempDir;
230
231    fn create_test_tree(dir: &Path) {
232        // Create a small directory tree with known content
233        std::fs::create_dir_all(dir.join("subdir")).unwrap();
234        let mut f1 = std::fs::File::create(dir.join("file_a.txt")).unwrap();
235        f1.write_all(b"hello world").unwrap();
236        let mut f2 = std::fs::File::create(dir.join("subdir/file_b.txt")).unwrap();
237        f2.write_all(b"nested content").unwrap();
238    }
239
240    #[tokio::test]
241    async fn test_tree_hash_deterministic() {
242        let tmp = TempDir::new().unwrap();
243        create_test_tree(tmp.path());
244
245        let h1 = compute_tree_hash(tmp.path()).await.unwrap();
246        let h2 = compute_tree_hash(tmp.path()).await.unwrap();
247        assert_eq!(h1.tree_hash, h2.tree_hash);
248        assert_eq!(h1.file_count, 2);
249    }
250
251    #[tokio::test]
252    async fn test_tree_hash_changes_on_modification() {
253        let tmp = TempDir::new().unwrap();
254        create_test_tree(tmp.path());
255
256        let h1 = compute_tree_hash(tmp.path()).await.unwrap();
257
258        // Modify a file
259        std::fs::write(tmp.path().join("file_a.txt"), b"changed").unwrap();
260
261        let h2 = compute_tree_hash(tmp.path()).await.unwrap();
262        assert_ne!(h1.tree_hash, h2.tree_hash);
263    }
264
265    #[tokio::test]
266    async fn test_store_and_load_tree_hash() {
267        let tmp = TempDir::new().unwrap();
268        create_test_tree(tmp.path());
269
270        let hash = compute_tree_hash(tmp.path()).await.unwrap();
271        store_tree_hash(tmp.path(), &hash).await.unwrap();
272
273        let loaded = load_tree_hash(tmp.path()).await.unwrap();
274        assert_eq!(hash.tree_hash, loaded.tree_hash);
275        assert_eq!(hash.file_count, loaded.file_count);
276    }
277
278    #[tokio::test]
279    async fn test_snapshot_and_verify() {
280        let src = TempDir::new().unwrap();
281        create_test_tree(src.path());
282
283        let store = TempDir::new().unwrap();
284        let mgr = StockGameManager::new(store.path().to_path_buf());
285
286        let snap = mgr
287            .snapshot(&GameId::from("test-game"), src.path())
288            .await
289            .unwrap();
290        assert!(!snap.hash.is_empty());
291
292        let ok = mgr.verify(&GameId::from("test-game")).await.unwrap();
293        assert!(ok);
294    }
295}