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}