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#[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}