winbrew_database/
msi_inventory.rs

1use anyhow::{Context, Result};
2use rusqlite::{Connection, OptionalExtension, params};
3
4use crate::models::install::engine::InstallScope;
5use crate::models::msi_inventory::records::{
6    MsiComponentRecord, MsiFileRecord, MsiInventoryReceipt, MsiInventorySnapshot,
7    MsiRegistryRecord, MsiShortcutRecord,
8};
9use crate::models::shared::hash::HashAlgorithm;
10
11pub fn upsert_receipt(conn: &Connection, receipt: &MsiInventoryReceipt) -> Result<()> {
12    conn.execute(
13        "INSERT INTO msi_receipts (package_name, product_code, upgrade_code, scope)
14         VALUES (?1, ?2, ?3, ?4)
15         ON CONFLICT(package_name) DO UPDATE SET
16             product_code = excluded.product_code,
17             upgrade_code = excluded.upgrade_code,
18             scope = excluded.scope",
19        params![
20            receipt.package_name,
21            receipt.product_code,
22            receipt.upgrade_code,
23            receipt.scope.to_string(),
24        ],
25    )
26    .context("failed to upsert MSI receipt")?;
27
28    Ok(())
29}
30
31pub fn apply_snapshot(conn: &Connection, snapshot: &MsiInventorySnapshot) -> Result<()> {
32    upsert_receipt(conn, &snapshot.receipt)?;
33
34    conn.execute(
35        "DELETE FROM msi_files WHERE package_name = ?1",
36        params![snapshot.receipt.package_name],
37    )
38    .context("failed to clear MSI file inventory")?;
39    conn.execute(
40        "DELETE FROM msi_registry_entries WHERE package_name = ?1",
41        params![snapshot.receipt.package_name],
42    )
43    .context("failed to clear MSI registry inventory")?;
44    conn.execute(
45        "DELETE FROM msi_shortcuts WHERE package_name = ?1",
46        params![snapshot.receipt.package_name],
47    )
48    .context("failed to clear MSI shortcut inventory")?;
49    conn.execute(
50        "DELETE FROM msi_components WHERE package_name = ?1",
51        params![snapshot.receipt.package_name],
52    )
53    .context("failed to clear MSI component inventory")?;
54
55    insert_files(conn, &snapshot.files)?;
56    insert_registry_entries(conn, &snapshot.registry_entries)?;
57    insert_shortcuts(conn, &snapshot.shortcuts)?;
58    insert_components(conn, &snapshot.components)?;
59
60    Ok(())
61}
62
63pub fn replace_snapshot(conn: &mut Connection, snapshot: &MsiInventorySnapshot) -> Result<()> {
64    let tx = conn
65        .transaction()
66        .context("failed to start MSI inventory transaction")?;
67
68    apply_snapshot(&tx, snapshot)?;
69
70    tx.commit()
71        .context("failed to commit MSI inventory snapshot")?;
72
73    Ok(())
74}
75
76pub fn get_snapshot(conn: &Connection, package_name: &str) -> Result<Option<MsiInventorySnapshot>> {
77    let Some(receipt) = load_receipt(conn, package_name)? else {
78        return Ok(None);
79    };
80
81    Ok(Some(MsiInventorySnapshot {
82        receipt,
83        files: load_files(conn, package_name)?,
84        registry_entries: load_registry_entries(conn, package_name)?,
85        shortcuts: load_shortcuts(conn, package_name)?,
86        components: load_components(conn, package_name)?,
87    }))
88}
89
90pub fn find_packages_by_normalized_path(
91    conn: &Connection,
92    normalized_path: &str,
93) -> Result<Vec<String>> {
94    let mut stmt = conn.prepare(
95        "SELECT package_name
96         FROM (
97             SELECT package_name FROM msi_files WHERE normalized_path = ?1
98             UNION
99             SELECT package_name FROM msi_shortcuts
100             WHERE normalized_path = ?1 OR normalized_target_path = ?1
101             UNION
102             SELECT package_name FROM msi_components WHERE normalized_path = ?1
103         )
104         ORDER BY package_name ASC",
105    )?;
106
107    stmt.query_map(params![normalized_path], |row| row.get::<_, String>(0))?
108        .collect::<std::result::Result<Vec<_>, _>>()
109        .context("failed to read MSI path owners")
110}
111
112pub fn find_packages_by_normalized_registry_key_path(
113    conn: &Connection,
114    normalized_key_path: &str,
115) -> Result<Vec<String>> {
116    let mut stmt = conn.prepare(
117        "SELECT package_name
118         FROM msi_registry_entries
119         WHERE normalized_key_path = ?1
120         ORDER BY package_name ASC",
121    )?;
122
123    stmt.query_map(params![normalized_key_path], |row| row.get::<_, String>(0))?
124        .collect::<std::result::Result<Vec<_>, _>>()
125        .context("failed to read MSI registry owners")
126}
127
128fn insert_files(conn: &Connection, files: &[MsiFileRecord]) -> Result<()> {
129    let mut stmt = conn.prepare(
130        "INSERT INTO msi_files
131         (package_name, path, normalized_path, hash_algorithm, hash_hex, is_config_file)
132         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
133    )?;
134
135    for file in files {
136        stmt.execute(params![
137            file.package_name,
138            file.path,
139            file.normalized_path,
140            file.hash_algorithm
141                .map(|algorithm: HashAlgorithm| algorithm.to_string()),
142            file.hash_hex,
143            file.is_config_file,
144        ])
145        .context("failed to insert MSI file inventory row")?;
146    }
147
148    Ok(())
149}
150
151fn insert_registry_entries(conn: &Connection, entries: &[MsiRegistryRecord]) -> Result<()> {
152    let mut stmt = conn.prepare(
153        "INSERT INTO msi_registry_entries
154         (package_name, hive, key_path, normalized_key_path, value_name, value_data, previous_value)
155         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
156         ON CONFLICT(package_name, hive, normalized_key_path, value_name) DO UPDATE SET
157             key_path = excluded.key_path,
158             value_data = excluded.value_data,
159             previous_value = excluded.previous_value",
160    )?;
161
162    for entry in entries {
163        stmt.execute(params![
164            entry.package_name,
165            entry.hive,
166            entry.key_path,
167            entry.normalized_key_path,
168            entry.value_name,
169            entry.value_data,
170            entry.previous_value,
171        ])
172        .context("failed to insert MSI registry inventory row")?;
173    }
174
175    Ok(())
176}
177
178fn insert_shortcuts(conn: &Connection, shortcuts: &[MsiShortcutRecord]) -> Result<()> {
179    let mut stmt = conn.prepare(
180        "INSERT INTO msi_shortcuts
181         (package_name, path, normalized_path, target_path, normalized_target_path)
182         VALUES (?1, ?2, ?3, ?4, ?5)",
183    )?;
184
185    for shortcut in shortcuts {
186        stmt.execute(params![
187            shortcut.package_name,
188            shortcut.path,
189            shortcut.normalized_path,
190            shortcut.target_path,
191            shortcut.normalized_target_path,
192        ])
193        .context("failed to insert MSI shortcut inventory row")?;
194    }
195
196    Ok(())
197}
198
199fn insert_components(conn: &Connection, components: &[MsiComponentRecord]) -> Result<()> {
200    let mut stmt = conn.prepare(
201        "INSERT INTO msi_components
202         (package_name, component_id, path, normalized_path)
203         VALUES (?1, ?2, ?3, ?4)",
204    )?;
205
206    for component in components {
207        stmt.execute(params![
208            component.package_name,
209            component.component_id,
210            component.path,
211            component.normalized_path,
212        ])
213        .context("failed to insert MSI component inventory row")?;
214    }
215
216    Ok(())
217}
218
219fn load_receipt(conn: &Connection, package_name: &str) -> Result<Option<MsiInventoryReceipt>> {
220    let mut stmt = conn.prepare(
221        "SELECT package_name, product_code, upgrade_code, scope
222         FROM msi_receipts
223         WHERE package_name = ?1",
224    )?;
225
226    let receipt = stmt
227        .query_row(params![package_name], |row| {
228            Ok((
229                row.get::<_, String>(0)?,
230                row.get::<_, String>(1)?,
231                row.get::<_, Option<String>>(2)?,
232                row.get::<_, String>(3)?,
233            ))
234        })
235        .optional()
236        .context("failed to read MSI receipt")?;
237
238    let Some((package_name, product_code, upgrade_code, scope_raw)) = receipt else {
239        return Ok(None);
240    };
241
242    let scope = scope_raw
243        .parse::<InstallScope>()
244        .with_context(|| format!("failed to parse MSI receipt scope for {package_name}"))?;
245
246    Ok(Some(MsiInventoryReceipt {
247        package_name,
248        product_code,
249        upgrade_code,
250        scope,
251    }))
252}
253
254fn load_files(conn: &Connection, package_name: &str) -> Result<Vec<MsiFileRecord>> {
255    let mut stmt = conn.prepare(
256        "SELECT package_name, path, normalized_path, hash_algorithm, hash_hex, is_config_file
257         FROM msi_files
258         WHERE package_name = ?1
259         ORDER BY normalized_path ASC",
260    )?;
261
262    let rows = stmt
263        .query_map(params![package_name], |row| {
264            Ok((
265                row.get::<_, String>(0)?,
266                row.get::<_, String>(1)?,
267                row.get::<_, String>(2)?,
268                row.get::<_, Option<String>>(3)?,
269                row.get::<_, Option<String>>(4)?,
270                row.get::<_, bool>(5)?,
271            ))
272        })?
273        .collect::<std::result::Result<Vec<_>, _>>()?;
274
275    rows.into_iter()
276        .map(
277            |(
278                package_name,
279                path,
280                normalized_path,
281                hash_algorithm_raw,
282                hash_hex,
283                is_config_file,
284            )| {
285                Ok(MsiFileRecord {
286                    package_name,
287                    path,
288                    normalized_path,
289                    hash_algorithm: hash_algorithm_raw
290                        .map(|value| value.parse::<HashAlgorithm>())
291                        .transpose()
292                        .context("failed to parse MSI file hash algorithm")?,
293                    hash_hex,
294                    is_config_file,
295                })
296            },
297        )
298        .collect()
299}
300
301fn load_registry_entries(conn: &Connection, package_name: &str) -> Result<Vec<MsiRegistryRecord>> {
302    let mut stmt = conn.prepare(
303        "SELECT package_name, hive, key_path, normalized_key_path, value_name, value_data, previous_value
304         FROM msi_registry_entries
305         WHERE package_name = ?1
306         ORDER BY hive ASC, normalized_key_path ASC, value_name ASC",
307    )?;
308
309    stmt.query_map(params![package_name], |row| {
310        Ok(MsiRegistryRecord {
311            package_name: row.get(0)?,
312            hive: row.get(1)?,
313            key_path: row.get(2)?,
314            normalized_key_path: row.get(3)?,
315            value_name: row.get(4)?,
316            value_data: row.get(5)?,
317            previous_value: row.get(6)?,
318        })
319    })?
320    .collect::<std::result::Result<Vec<_>, _>>()
321    .context("failed to read MSI registry entries")
322}
323
324fn load_shortcuts(conn: &Connection, package_name: &str) -> Result<Vec<MsiShortcutRecord>> {
325    let mut stmt = conn.prepare(
326        "SELECT package_name, path, normalized_path, target_path, normalized_target_path
327         FROM msi_shortcuts
328         WHERE package_name = ?1
329         ORDER BY normalized_path ASC",
330    )?;
331
332    stmt.query_map(params![package_name], |row| {
333        Ok(MsiShortcutRecord {
334            package_name: row.get(0)?,
335            path: row.get(1)?,
336            normalized_path: row.get(2)?,
337            target_path: row.get(3)?,
338            normalized_target_path: row.get(4)?,
339        })
340    })?
341    .collect::<std::result::Result<Vec<_>, _>>()
342    .context("failed to read MSI shortcuts")
343}
344
345fn load_components(conn: &Connection, package_name: &str) -> Result<Vec<MsiComponentRecord>> {
346    let mut stmt = conn.prepare(
347        "SELECT package_name, component_id, path, normalized_path
348         FROM msi_components
349         WHERE package_name = ?1
350         ORDER BY component_id ASC",
351    )?;
352
353    stmt.query_map(params![package_name], |row| {
354        Ok(MsiComponentRecord {
355            package_name: row.get(0)?,
356            component_id: row.get(1)?,
357            path: row.get(2)?,
358            normalized_path: row.get(3)?,
359        })
360    })?
361    .collect::<std::result::Result<Vec<_>, _>>()
362    .context("failed to read MSI components")
363}
364
365#[cfg(test)]
366mod tests {
367    use super::{
368        HashAlgorithm, find_packages_by_normalized_path,
369        find_packages_by_normalized_registry_key_path, get_snapshot, insert_registry_entries,
370        replace_snapshot,
371    };
372    use crate::models::install::engine::{EngineKind, EngineMetadata, InstallScope};
373    use crate::models::install::installed::{InstalledPackage, PackageStatus};
374    use crate::models::install::installer::InstallerType;
375    use crate::models::msi_inventory::records::{
376        MsiComponentRecord, MsiFileRecord, MsiInventoryReceipt, MsiInventorySnapshot,
377        MsiRegistryRecord, MsiShortcutRecord,
378    };
379    use crate::{insert_package, migration};
380    use rusqlite::Connection;
381
382    fn sample_package(name: &str) -> InstalledPackage {
383        InstalledPackage {
384            name: name.to_string(),
385            version: "1.0.0".to_string(),
386            kind: InstallerType::Msi,
387            deployment_kind: InstallerType::Msi.deployment_kind(),
388            engine_kind: EngineKind::Msi,
389            engine_metadata: Some(EngineMetadata::Msi {
390                product_code: "{11111111-1111-1111-1111-111111111111}".to_string(),
391                upgrade_code: None,
392                scope: InstallScope::Installed,
393                registry_keys: Vec::new(),
394                shortcuts: Vec::new(),
395            }),
396            install_dir: "C:/Tools/Demo".to_string(),
397            dependencies: Vec::new(),
398            status: PackageStatus::Ok,
399            installed_at: "2026-04-12T00:00:00Z".to_string(),
400        }
401    }
402
403    fn sample_snapshot(name: &str) -> MsiInventorySnapshot {
404        MsiInventorySnapshot {
405            receipt: MsiInventoryReceipt {
406                package_name: name.to_string(),
407                product_code: "{11111111-1111-1111-1111-111111111111}".to_string(),
408                upgrade_code: Some("{22222222-2222-2222-2222-222222222222}".to_string()),
409                scope: InstallScope::Installed,
410            },
411            files: vec![MsiFileRecord {
412                package_name: name.to_string(),
413                path: "C:/Tools/Demo/bin/demo.exe".to_string(),
414                normalized_path: "c:/tools/demo/bin/demo.exe".to_string(),
415                hash_algorithm: Some(HashAlgorithm::Sha256),
416                hash_hex: Some(
417                    "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
418                ),
419                is_config_file: false,
420            }],
421            registry_entries: vec![MsiRegistryRecord {
422                package_name: name.to_string(),
423                hive: "HKLM".to_string(),
424                key_path: "Software\\Demo".to_string(),
425                normalized_key_path: "software\\demo".to_string(),
426                value_name: "InstallPath".to_string(),
427                value_data: Some("C:/Tools/Demo".to_string()),
428                previous_value: None,
429            }],
430            shortcuts: vec![MsiShortcutRecord {
431                package_name: name.to_string(),
432                path: "C:/Users/Public/Desktop/Demo.lnk".to_string(),
433                normalized_path: "c:/users/public/desktop/demo.lnk".to_string(),
434                target_path: Some("C:/Tools/Demo/bin/demo.exe".to_string()),
435                normalized_target_path: Some("c:/tools/demo/bin/demo.exe".to_string()),
436            }],
437            components: vec![MsiComponentRecord {
438                package_name: name.to_string(),
439                component_id: "COMPONENT-DEMO".to_string(),
440                path: Some("C:/Tools/Demo/bin/demo.exe".to_string()),
441                normalized_path: Some("c:/tools/demo/bin/demo.exe".to_string()),
442            }],
443        }
444    }
445
446    #[test]
447    fn replace_snapshot_persists_inventory_and_reverse_lookup() {
448        let conn = Connection::open_in_memory().expect("open in-memory database");
449        migration::migrate(&conn).expect("run migration");
450
451        let package_name = "demo";
452        insert_package(&conn, &sample_package(package_name)).expect("insert package");
453
454        let mut conn = conn;
455        replace_snapshot(&mut conn, &sample_snapshot(package_name)).expect("replace snapshot");
456
457        let snapshot = get_snapshot(&conn, package_name)
458            .expect("load snapshot")
459            .expect("snapshot present");
460        assert_eq!(snapshot, sample_snapshot(package_name));
461
462        let file_owners = find_packages_by_normalized_path(&conn, "c:/tools/demo/bin/demo.exe")
463            .expect("lookup file owners");
464        assert_eq!(file_owners, vec![package_name.to_string()]);
465
466        let registry_owners =
467            find_packages_by_normalized_registry_key_path(&conn, "software\\demo")
468                .expect("lookup registry owners");
469        assert_eq!(registry_owners, vec![package_name.to_string()]);
470    }
471
472    #[test]
473    fn replace_snapshot_overwrites_previous_rows() {
474        let conn = Connection::open_in_memory().expect("open in-memory database");
475        migration::migrate(&conn).expect("run migration");
476
477        let package_name = "demo";
478        insert_package(&conn, &sample_package(package_name)).expect("insert package");
479
480        let mut conn = conn;
481        replace_snapshot(&mut conn, &sample_snapshot(package_name))
482            .expect("insert initial snapshot");
483
484        let mut updated = sample_snapshot(package_name);
485        updated.files = vec![MsiFileRecord {
486            package_name: package_name.to_string(),
487            path: "C:/Tools/Demo/bin/demo2.exe".to_string(),
488            normalized_path: "c:/tools/demo/bin/demo2.exe".to_string(),
489            hash_algorithm: Some(HashAlgorithm::Sha256),
490            hash_hex: Some(
491                "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210".to_string(),
492            ),
493            is_config_file: true,
494        }];
495        updated.shortcuts = vec![MsiShortcutRecord {
496            package_name: package_name.to_string(),
497            path: "C:/Users/Public/Desktop/Demo2.lnk".to_string(),
498            normalized_path: "c:/users/public/desktop/demo2.lnk".to_string(),
499            target_path: Some("C:/Tools/Demo/bin/demo2.exe".to_string()),
500            normalized_target_path: Some("c:/tools/demo/bin/demo2.exe".to_string()),
501        }];
502        updated.components = vec![MsiComponentRecord {
503            package_name: package_name.to_string(),
504            component_id: "COMPONENT-DEMO-2".to_string(),
505            path: Some("C:/Tools/Demo/bin/demo2.exe".to_string()),
506            normalized_path: Some("c:/tools/demo/bin/demo2.exe".to_string()),
507        }];
508
509        replace_snapshot(&mut conn, &updated).expect("replace snapshot");
510
511        let old_owners = find_packages_by_normalized_path(&conn, "c:/tools/demo/bin/demo.exe")
512            .expect("lookup old owners");
513        assert!(old_owners.is_empty());
514
515        let new_owners = find_packages_by_normalized_path(&conn, "c:/tools/demo/bin/demo2.exe")
516            .expect("lookup new owners");
517        assert_eq!(new_owners, vec![package_name.to_string()]);
518    }
519
520    #[test]
521    fn insert_registry_entries_upserts_duplicate_primary_keys() {
522        let conn = Connection::open_in_memory().expect("open in-memory database");
523        migration::migrate(&conn).expect("run migration");
524
525        let package_name = "demo";
526        insert_package(&conn, &sample_package(package_name)).expect("insert package");
527
528        let snapshot = sample_snapshot(package_name);
529        insert_registry_entries(&conn, &snapshot.registry_entries)
530            .expect("insert initial registry rows");
531
532        let mut updated = sample_snapshot(package_name);
533        updated.registry_entries[0].key_path = "Software\\Demo\\Updated".to_string();
534        updated.registry_entries[0].value_data = Some("C:/Tools/Demo2".to_string());
535        updated.registry_entries[0].previous_value = Some("C:/Tools/Demo".to_string());
536
537        insert_registry_entries(&conn, &updated.registry_entries)
538            .expect("upsert duplicate registry rows");
539
540        let registry_count: i64 = conn
541            .query_row(
542                "SELECT COUNT(*) FROM msi_registry_entries WHERE package_name = ?1",
543                [package_name],
544                |row| row.get(0),
545            )
546            .expect("count registry rows");
547        assert_eq!(registry_count, 1);
548
549        let (key_path, value_data, previous_value): (String, Option<String>, Option<String>) = conn
550            .query_row(
551                "SELECT key_path, value_data, previous_value
552                 FROM msi_registry_entries
553                 WHERE package_name = ?1 AND hive = ?2 AND normalized_key_path = ?3 AND value_name = ?4",
554                rusqlite::params![
555                    package_name,
556                    "HKLM",
557                    "software\\demo",
558                    "InstallPath"
559                ],
560                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
561            )
562            .expect("load registry row");
563
564        assert_eq!(key_path, "Software\\Demo\\Updated");
565        assert_eq!(value_data.as_deref(), Some("C:/Tools/Demo2"));
566        assert_eq!(previous_value.as_deref(), Some("C:/Tools/Demo"));
567    }
568}