winbrew_database\journal/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4use crate::models::command_resolution::ResolverResult;
5use crate::models::install::engine::EngineMetadata;
6use crate::models::shared::DeploymentKind;
7use crate::models::shared::hash::HashAlgorithm;
8
9mod key;
10mod reader;
11mod replay;
12mod writer;
13
14pub use key::package_journal_key;
15pub use reader::{JournalReadError, JournalReader};
16pub use replay::{CommittedJournalPackage, JournalReplayError};
17pub use writer::JournalWriter;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum HashAlgo {
22    Md5,
23    Sha1,
24    Sha256,
25    Sha512,
26}
27
28impl HashAlgo {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::Md5 => "md5",
32            Self::Sha1 => "sha1",
33            Self::Sha256 => "sha256",
34            Self::Sha512 => "sha512",
35        }
36    }
37
38    pub fn expected_hex_len(self) -> usize {
39        match self {
40            Self::Md5 => 32,
41            Self::Sha1 => 40,
42            Self::Sha256 => 64,
43            Self::Sha512 => 128,
44        }
45    }
46
47    pub fn is_secure(self) -> bool {
48        matches!(self, Self::Sha256 | Self::Sha512)
49    }
50}
51
52impl fmt::Display for HashAlgo {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(self.as_str())
55    }
56}
57
58impl From<HashAlgorithm> for HashAlgo {
59    fn from(value: HashAlgorithm) -> Self {
60        match value {
61            HashAlgorithm::Md5 => Self::Md5,
62            HashAlgorithm::Sha1 => Self::Sha1,
63            HashAlgorithm::Sha256 => Self::Sha256,
64            HashAlgorithm::Sha512 => Self::Sha512,
65        }
66    }
67}
68
69impl From<HashAlgo> for HashAlgorithm {
70    fn from(value: HashAlgo) -> Self {
71        match value {
72            HashAlgo::Md5 => Self::Md5,
73            HashAlgo::Sha1 => Self::Sha1,
74            HashAlgo::Sha256 => Self::Sha256,
75            HashAlgo::Sha512 => Self::Sha512,
76        }
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct FileHash {
82    pub algo: HashAlgo,
83    pub hex: String,
84}
85
86impl FileHash {
87    pub fn new(algo: HashAlgo, hex: impl Into<String>) -> Self {
88        let hex = hex.into();
89        debug_assert!(
90            hex.chars().all(|c| c.is_ascii_hexdigit()),
91            "FileHash hex must be valid hexadecimal"
92        );
93        debug_assert_eq!(
94            hex.len(),
95            algo.expected_hex_len(),
96            "FileHash hex length mismatch for {algo}"
97        );
98
99        Self { algo, hex }
100    }
101
102    pub fn as_prefixed_string(&self) -> String {
103        format!("{}:{}", self.algo, self.hex)
104    }
105}
106
107impl fmt::Display for FileHash {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.write_str(&self.as_prefixed_string())
110    }
111}
112
113/// Lossless command shim binding captured at install time.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct JournalShimBinding {
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub alias: Option<String>,
118    pub target_path: String,
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub default_args: Vec<String>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[allow(clippy::large_enum_variant)]
125#[serde(tag = "action", rename_all = "snake_case")]
126pub enum JournalEntry {
127    Metadata {
128        #[serde(default)]
129        package_id: String,
130        #[serde(default)]
131        version: String,
132        #[serde(default)]
133        engine: String,
134        deployment_kind: DeploymentKind,
135        #[serde(default, skip_serializing_if = "String::is_empty")]
136        install_dir: String,
137        #[serde(default, skip_serializing_if = "Vec::is_empty")]
138        dependencies: Vec<String>,
139        #[serde(default, skip_serializing_if = "Option::is_none")]
140        commands: Option<Vec<String>>,
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        bin: Option<Vec<String>>,
143        #[serde(default, skip_serializing_if = "Option::is_none")]
144        bin_bindings: Option<Vec<JournalShimBinding>>,
145        #[serde(default, skip_serializing_if = "Vec::is_empty")]
146        env_add_path: Vec<String>,
147        #[serde(default, skip_serializing_if = "Option::is_none")]
148        command_resolution: Option<ResolverResult>,
149        #[serde(default, skip_serializing_if = "Option::is_none")]
150        engine_metadata: Option<EngineMetadata>,
151    },
152    FsCreate {
153        path: String,
154        hash: Option<FileHash>,
155    },
156    FsDelete {
157        path: String,
158        hash: Option<FileHash>,
159    },
160    RegSet {
161        hive: String,
162        key: String,
163        value: String,
164        previous_value: Option<String>,
165    },
166    Shortcut {
167        path: String,
168        target: Option<String>,
169    },
170    Component {
171        id: String,
172        path: Option<String>,
173    },
174    Commit {
175        #[serde(default, skip_serializing_if = "String::is_empty")]
176        installed_at: String,
177    },
178}
179
180#[cfg(test)]
181mod tests {
182    use super::package_journal_key;
183    use super::{FileHash, HashAlgo, JournalEntry, JournalReadError, JournalReader, JournalWriter};
184    use crate::core::{ResolvedPaths, resolved_paths};
185    use crate::models::install::installer::InstallerType;
186    use std::fs;
187    use std::path::Path;
188    use std::path::PathBuf;
189    use std::process;
190    use std::time::{SystemTime, UNIX_EPOCH};
191
192    fn temp_root() -> PathBuf {
193        let unique_id = SystemTime::now()
194            .duration_since(UNIX_EPOCH)
195            .expect("system clock should be after unix epoch")
196            .as_nanos();
197
198        let root = std::env::temp_dir().join(format!(
199            "winbrew-database-journal-{}-{unique_id}",
200            process::id()
201        ));
202        let paths = resolved_root_paths(&root);
203
204        crate::init(&paths).expect("bootstrap journal test root");
205        for package_key in [
206            package_journal_key("winget/Contoso.App", "1.0.0"),
207            package_journal_key("winget/Contoso.Committed", "1.0.0"),
208            package_journal_key("winget/Contoso.Incomplete", "1.0.0"),
209        ] {
210            fs::create_dir_all(paths.package_journal_dir(&package_key))
211                .expect("create journal package directory");
212        }
213
214        root
215    }
216
217    fn resolved_root_paths(root: &Path) -> ResolvedPaths {
218        let packages = root.join("packages").to_string_lossy().into_owned();
219        let data = root.join("data").to_string_lossy().into_owned();
220        let logs = root.join("logs").to_string_lossy().into_owned();
221        let cache = root.join("cache").to_string_lossy().into_owned();
222
223        resolved_paths(root, &packages, &data, &logs, &cache)
224    }
225
226    fn metadata_entry() -> JournalEntry {
227        JournalEntry::Metadata {
228            package_id: "winget/Contoso.App".to_string(),
229            version: "1.0.0".to_string(),
230            engine: "msi".to_string(),
231            deployment_kind: crate::models::shared::DeploymentKind::Installed,
232            install_dir: r"C:\winbrew\apps\Contoso.App".to_string(),
233            dependencies: vec!["winget/Contoso.Shared".to_string()],
234            commands: None,
235            bin: None,
236            bin_bindings: None,
237            env_add_path: Vec::new(),
238            command_resolution: None,
239            engine_metadata: None,
240        }
241    }
242
243    fn commit_entry() -> JournalEntry {
244        JournalEntry::Commit {
245            installed_at: "2024-01-01T00:00:00Z".to_string(),
246        }
247    }
248
249    #[test]
250    fn journal_entries_are_written_as_jsonl() {
251        let root = temp_root();
252        let mut writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
253            .expect("open journal");
254
255        writer.append(&metadata_entry()).expect("write metadata");
256        writer
257            .append(&JournalEntry::FsCreate {
258                path: r"C:\winbrew\apps\Contoso.App\app.exe".to_string(),
259                hash: Some(FileHash::new(
260                    HashAlgo::Sha256,
261                    "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
262                )),
263            })
264            .expect("write fs create");
265        writer
266            .append(&JournalEntry::RegSet {
267                hive: "HKCU".to_string(),
268                key: r"Software\Classes\*\shell\Open with Code".to_string(),
269                value: "command".to_string(),
270                previous_value: None,
271            })
272            .expect("write reg set");
273        writer.append(&commit_entry()).expect("write commit");
274        writer.flush().expect("flush journal");
275
276        let contents = fs::read_to_string(writer.path()).expect("read journal");
277        let lines = contents.lines().collect::<Vec<_>>();
278
279        assert_eq!(lines.len(), 4);
280        assert!(matches!(
281            serde_json::from_str::<JournalEntry>(lines[0]).expect("parse metadata"),
282            JournalEntry::Metadata { .. }
283        ));
284        assert!(matches!(
285            serde_json::from_str::<JournalEntry>(lines[1]).expect("parse fs create"),
286            JournalEntry::FsCreate { .. }
287        ));
288        assert!(matches!(
289            serde_json::from_str::<JournalEntry>(lines[2]).expect("parse reg set"),
290            JournalEntry::RegSet { .. }
291        ));
292        assert!(matches!(
293            serde_json::from_str::<JournalEntry>(lines[3]).expect("parse commit"),
294            JournalEntry::Commit { .. }
295        ));
296    }
297
298    #[test]
299    fn journal_entries_are_written_under_resolved_paths() {
300        let root = temp_root();
301        let paths = resolved_root_paths(&root);
302        let package_key = package_journal_key("winget/Contoso.App", "1.0.0");
303
304        let writer = JournalWriter::open_for_package_in(&paths, "winget/Contoso.App", "1.0.0")
305            .expect("open journal");
306
307        assert_eq!(writer.path(), paths.package_journal_file(&package_key));
308    }
309
310    #[test]
311    fn journal_reader_requires_commit_marker() {
312        let root = temp_root();
313        let mut writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
314            .expect("open journal");
315
316        writer.append(&metadata_entry()).expect("write metadata");
317        writer.flush().expect("flush journal");
318
319        let err =
320            JournalReader::read_committed(writer.path()).expect_err("journal should be incomplete");
321
322        assert!(matches!(err, JournalReadError::Incomplete { .. }));
323    }
324
325    #[test]
326    fn journal_reader_rejects_trailing_entries_after_commit() {
327        let root = temp_root();
328        let mut writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
329            .expect("open journal");
330
331        writer.append(&commit_entry()).expect("write commit");
332        writer
333            .append(&metadata_entry())
334            .expect("write trailing metadata");
335        writer.flush().expect("flush journal");
336
337        let err =
338            JournalReader::read_committed(writer.path()).expect_err("journal should be rejected");
339
340        assert!(matches!(err, JournalReadError::TrailingEntries { .. }));
341    }
342
343    #[test]
344    fn journal_reader_rejects_empty_file() {
345        let root = temp_root();
346        let writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
347            .expect("open journal");
348
349        fs::write(writer.path(), b"").expect("truncate journal to empty");
350
351        let err = JournalReader::read_committed(writer.path())
352            .expect_err("empty file should be incomplete");
353
354        assert!(matches!(err, JournalReadError::Incomplete { .. }));
355    }
356
357    #[test]
358    fn read_committed_package_journal_ignores_trailing_entries() {
359        let root = temp_root();
360        let mut writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
361            .expect("open journal");
362
363        writer.append(&metadata_entry()).expect("write metadata");
364        writer.append(&commit_entry()).expect("write commit");
365        writer
366            .append(&JournalEntry::FsCreate {
367                path: r"C:\winbrew\apps\Contoso.App\payload.exe".to_string(),
368                hash: None,
369            })
370            .expect("write trailing entry");
371        writer.flush().expect("flush journal");
372
373        let replay = JournalReader::read_committed_package(writer.path())
374            .expect("parse replay journal with trailing entries");
375
376        assert_eq!(replay.journal_path, writer.path());
377        assert_eq!(replay.entries, vec![metadata_entry(), commit_entry()]);
378        assert_eq!(replay.package.name, "winget/Contoso.App");
379        assert_eq!(replay.package.version, "1.0.0");
380    }
381
382    #[test]
383    fn journal_reader_rejects_whitespace_only_file() {
384        let root = temp_root();
385        let writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
386            .expect("open journal");
387
388        fs::write(writer.path(), b"\n  \n\t\n").expect("write whitespace-only journal");
389
390        let err = JournalReader::read_committed(writer.path())
391            .expect_err("whitespace-only file should be incomplete");
392
393        assert!(matches!(err, JournalReadError::Incomplete { .. }));
394    }
395
396    #[test]
397    fn journal_reader_accepts_commit_only() {
398        let root = temp_root();
399        let mut writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
400            .expect("open journal");
401
402        writer.append(&commit_entry()).expect("write commit");
403        writer.flush().expect("flush journal");
404
405        let entries = JournalReader::read_committed(writer.path())
406            .expect("commit-only journal should be accepted");
407
408        assert_eq!(entries, vec![commit_entry()]);
409    }
410
411    #[test]
412    fn journal_reader_rejects_malformed_line() {
413        let root = temp_root();
414        let writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
415            .expect("open journal");
416
417        fs::write(
418            writer.path(),
419            b"{\"action\":\"metadata\",\"package_id\":\"winget/Contoso.App\",\"version\":\"1.0.0\",\"engine\":\"msi\",\"deployment_kind\":\"installed\"}\n{not-json}\n",
420        )
421        .expect("write malformed journal");
422
423        let err = JournalReader::read_committed(writer.path())
424            .expect_err("malformed line should be rejected");
425
426        assert!(matches!(
427            err,
428            JournalReadError::MalformedLine { line: 2, .. }
429        ));
430    }
431
432    #[test]
433    fn open_for_package_rejects_already_committed_journal() {
434        let root = temp_root();
435        let mut writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
436            .expect("open journal");
437
438        writer.append(&commit_entry()).expect("write commit");
439        writer.flush().expect("flush journal");
440        let journal_path = writer.path().to_path_buf();
441        drop(writer);
442
443        let err = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
444            .expect_err("committed journal should be rejected");
445
446        assert!(err.to_string().contains("already committed"));
447        assert!(journal_path.exists());
448    }
449
450    #[test]
451    fn open_for_package_allows_resuming_incomplete_journal() {
452        let root = temp_root();
453        let mut writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
454            .expect("open journal");
455
456        writer.append(&metadata_entry()).expect("write metadata");
457        writer.flush().expect("flush journal");
458        let journal_path = writer.path().to_path_buf();
459        drop(writer);
460
461        let mut resumed = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
462            .expect("resume incomplete journal");
463        resumed.append(&commit_entry()).expect("write commit");
464        resumed.flush().expect("flush resumed journal");
465
466        let parsed = JournalReader::read_committed(&journal_path).expect("read committed journal");
467
468        assert_eq!(parsed, vec![metadata_entry(), commit_entry(),]);
469    }
470
471    #[test]
472    fn journal_round_trip_preserves_entries() {
473        let root = temp_root();
474        let mut writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
475            .expect("open journal");
476
477        let original = vec![
478            metadata_entry(),
479            JournalEntry::FsCreate {
480                path: r"C:\winbrew\apps\Contoso.App\app.exe".to_string(),
481                hash: Some(FileHash::new(
482                    HashAlgo::Sha512,
483                    "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
484                )),
485            },
486            JournalEntry::FsDelete {
487                path: r"C:\winbrew\apps\Contoso.App\old.exe".to_string(),
488                hash: None,
489            },
490            commit_entry(),
491        ];
492
493        for entry in &original {
494            writer.append(entry).expect("write entry");
495        }
496        writer.flush().expect("flush journal");
497
498        let parsed = JournalReader::read_committed(writer.path()).expect("read journal");
499
500        assert_eq!(parsed, original);
501    }
502
503    #[test]
504    fn committed_journal_paths_returns_only_committed_journals() {
505        let root = temp_root();
506        let paths = resolved_root_paths(&root);
507
508        let mut committed =
509            JournalWriter::open_for_package(&root, "winget/Contoso.Committed", "1.0.0")
510                .expect("open committed journal");
511        committed
512            .append(&JournalEntry::Metadata {
513                package_id: "winget/Contoso.Committed".to_string(),
514                version: "1.0.0".to_string(),
515                engine: "msi".to_string(),
516                deployment_kind: crate::models::shared::DeploymentKind::Installed,
517                install_dir: r"C:\winbrew\apps\Contoso.Committed".to_string(),
518                dependencies: Vec::new(),
519                commands: None,
520                bin: None,
521                bin_bindings: None,
522                env_add_path: Vec::new(),
523                command_resolution: None,
524                engine_metadata: None,
525            })
526            .expect("write committed metadata");
527        committed.append(&commit_entry()).expect("write commit");
528        committed.flush().expect("flush committed journal");
529
530        let mut incomplete =
531            JournalWriter::open_for_package(&root, "winget/Contoso.Incomplete", "1.0.0")
532                .expect("open incomplete journal");
533        incomplete
534            .append(&JournalEntry::Metadata {
535                package_id: "winget/Contoso.Incomplete".to_string(),
536                version: "1.0.0".to_string(),
537                engine: "msi".to_string(),
538                deployment_kind: crate::models::shared::DeploymentKind::Installed,
539                install_dir: r"C:\winbrew\apps\Contoso.Incomplete".to_string(),
540                dependencies: Vec::new(),
541                commands: None,
542                bin: None,
543                bin_bindings: None,
544                env_add_path: Vec::new(),
545                command_resolution: None,
546                engine_metadata: None,
547            })
548            .expect("write incomplete metadata");
549        incomplete.flush().expect("flush incomplete journal");
550
551        let journal_paths =
552            JournalReader::committed_paths_in(&paths).expect("enumerate committed journals");
553
554        assert_eq!(journal_paths, vec![committed.path().to_path_buf()]);
555    }
556
557    #[test]
558    fn read_committed_package_journal_parses_snapshot() {
559        let root = temp_root();
560        let mut writer = JournalWriter::open_for_package(&root, "winget/Contoso.App", "1.0.0")
561            .expect("open journal");
562
563        writer.append(&metadata_entry()).expect("write metadata");
564        writer.append(&commit_entry()).expect("write commit");
565        writer.flush().expect("flush journal");
566
567        let replay =
568            JournalReader::read_committed_package(writer.path()).expect("parse replay journal");
569
570        assert_eq!(replay.journal_path, writer.path());
571        assert_eq!(replay.entries.len(), 2);
572        assert_eq!(replay.package.name, "winget/Contoso.App");
573        assert_eq!(replay.package.version, "1.0.0");
574        assert_eq!(replay.package.kind, InstallerType::Msi);
575        assert_eq!(replay.package.install_dir, r"C:\winbrew\apps\Contoso.App");
576        assert_eq!(
577            replay.package.dependencies,
578            vec!["winget/Contoso.Shared".to_string()]
579        );
580        assert_eq!(replay.package.installed_at, "2024-01-01T00:00:00Z");
581    }
582
583    #[test]
584    fn hash_algo_reports_security_profile() {
585        assert!(!HashAlgo::Md5.is_secure());
586        assert!(!HashAlgo::Sha1.is_secure());
587        assert!(HashAlgo::Sha256.is_secure());
588        assert!(HashAlgo::Sha512.is_secure());
589    }
590}