1use crate::{
2 artifacts::{ArtifactChecksum, ArtifactChecksumError},
3 journal::{ArtifactState, DownloadJournal},
4 manifest::{
5 FleetBackupManifest, ManifestValidationError, backup_unit_kind_name, consistency_mode_name,
6 },
7};
8use serde::{Deserialize, Serialize, de::DeserializeOwned};
9use std::{
10 collections::{BTreeMap, BTreeSet},
11 fs::{self, File},
12 io,
13 path::{Path, PathBuf},
14};
15use thiserror::Error as ThisError;
16
17const MANIFEST_FILE_NAME: &str = "fleet-backup-manifest.json";
18const JOURNAL_FILE_NAME: &str = "download-journal.json";
19
20#[derive(Clone, Debug)]
25pub struct BackupLayout {
26 root: PathBuf,
27}
28
29impl BackupLayout {
30 #[must_use]
32 pub const fn new(root: PathBuf) -> Self {
33 Self { root }
34 }
35
36 #[must_use]
38 pub fn root(&self) -> &Path {
39 &self.root
40 }
41
42 #[must_use]
44 pub fn manifest_path(&self) -> PathBuf {
45 self.root.join(MANIFEST_FILE_NAME)
46 }
47
48 #[must_use]
50 pub fn journal_path(&self) -> PathBuf {
51 self.root.join(JOURNAL_FILE_NAME)
52 }
53
54 pub fn write_manifest(&self, manifest: &FleetBackupManifest) -> Result<(), PersistenceError> {
56 manifest.validate()?;
57 write_json_atomic(&self.manifest_path(), manifest)
58 }
59
60 pub fn read_manifest(&self) -> Result<FleetBackupManifest, PersistenceError> {
62 let manifest = read_json(&self.manifest_path())?;
63 FleetBackupManifest::validate(&manifest)?;
64 Ok(manifest)
65 }
66
67 pub fn write_journal(&self, journal: &DownloadJournal) -> Result<(), PersistenceError> {
69 journal.validate()?;
70 write_json_atomic(&self.journal_path(), journal)
71 }
72
73 pub fn read_journal(&self) -> Result<DownloadJournal, PersistenceError> {
75 let journal = read_json(&self.journal_path())?;
76 DownloadJournal::validate(&journal)?;
77 Ok(journal)
78 }
79
80 pub fn verify_integrity(&self) -> Result<BackupIntegrityReport, PersistenceError> {
82 let manifest = self.read_manifest()?;
83 let journal = self.read_journal()?;
84 verify_layout_integrity(self, &manifest, &journal)
85 }
86
87 pub fn inspect(&self) -> Result<BackupInspectionReport, PersistenceError> {
89 let manifest = self.read_manifest()?;
90 let journal = self.read_journal()?;
91 Ok(inspect_layout(&manifest, &journal))
92 }
93
94 pub fn provenance(&self) -> Result<BackupProvenanceReport, PersistenceError> {
96 let manifest = self.read_manifest()?;
97 let journal = self.read_journal()?;
98 Ok(provenance_report(&manifest, &journal))
99 }
100}
101
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
107pub struct BackupProvenanceReport {
108 pub backup_id: String,
109 pub manifest_backup_id: String,
110 pub journal_backup_id: String,
111 pub backup_id_matches: bool,
112 pub manifest_version: u16,
113 pub journal_version: u16,
114 pub created_at: String,
115 pub tool_name: String,
116 pub tool_version: String,
117 pub source_environment: String,
118 pub source_root_canister: String,
119 pub topology_hash_algorithm: String,
120 pub topology_hash_input: String,
121 pub discovery_topology_hash: String,
122 pub pre_snapshot_topology_hash: String,
123 pub accepted_topology_hash: String,
124 pub journal_discovery_topology_hash: Option<String>,
125 pub journal_pre_snapshot_topology_hash: Option<String>,
126 pub topology_receipts_match: bool,
127 pub topology_receipt_mismatches: Vec<TopologyReceiptMismatch>,
128 pub backup_unit_count: usize,
129 pub member_count: usize,
130 pub consistency_mode: String,
131 pub backup_units: Vec<BackupUnitProvenance>,
132 pub members: Vec<MemberSnapshotProvenance>,
133}
134
135#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
140pub struct BackupUnitProvenance {
141 pub unit_id: String,
142 pub kind: String,
143 pub roles: Vec<String>,
144 pub consistency_reason: Option<String>,
145 pub dependency_closure: Vec<String>,
146 pub topology_validation: String,
147 pub quiescence_strategy: Option<String>,
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
155pub struct MemberSnapshotProvenance {
156 pub canister_id: String,
157 pub role: String,
158 pub parent_canister_id: Option<String>,
159 pub subnet_canister_id: Option<String>,
160 pub identity_mode: String,
161 pub restore_group: u16,
162 pub verification_class: String,
163 pub verification_checks: usize,
164 pub snapshot_id: String,
165 pub module_hash: Option<String>,
166 pub wasm_hash: Option<String>,
167 pub code_version: Option<String>,
168 pub artifact_path: String,
169 pub checksum_algorithm: String,
170 pub manifest_checksum: Option<String>,
171 pub journal_state: Option<String>,
172 pub journal_checksum: Option<String>,
173 pub journal_updated_at: Option<String>,
174}
175
176#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
181pub struct BackupInspectionReport {
182 pub backup_id: String,
183 pub manifest_backup_id: String,
184 pub journal_backup_id: String,
185 pub backup_id_matches: bool,
186 pub journal_complete: bool,
187 pub ready_for_verify: bool,
188 pub manifest_members: usize,
189 pub journal_artifacts: usize,
190 pub matched_artifacts: usize,
191 pub topology_receipt_mismatches: Vec<TopologyReceiptMismatch>,
192 pub missing_journal_artifacts: Vec<ArtifactReference>,
193 pub unexpected_journal_artifacts: Vec<ArtifactReference>,
194 pub path_mismatches: Vec<ArtifactPathMismatch>,
195 pub checksum_mismatches: Vec<ArtifactChecksumMismatch>,
196}
197
198#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
203pub struct TopologyReceiptMismatch {
204 pub field: String,
205 pub manifest: String,
206 pub journal: Option<String>,
207}
208
209#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
214pub struct ArtifactReference {
215 pub canister_id: String,
216 pub snapshot_id: String,
217}
218
219#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
224pub struct ArtifactPathMismatch {
225 pub canister_id: String,
226 pub snapshot_id: String,
227 pub manifest: String,
228 pub journal: String,
229}
230
231#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236pub struct ArtifactChecksumMismatch {
237 pub canister_id: String,
238 pub snapshot_id: String,
239 pub manifest: String,
240 pub journal: String,
241}
242
243#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
248pub struct BackupIntegrityReport {
249 pub backup_id: String,
250 pub verified: bool,
251 pub manifest_members: usize,
252 pub journal_artifacts: usize,
253 pub durable_artifacts: usize,
254 pub artifacts: Vec<ArtifactIntegrityReport>,
255}
256
257#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
262pub struct ArtifactIntegrityReport {
263 pub canister_id: String,
264 pub snapshot_id: String,
265 pub artifact_path: String,
266 pub checksum: String,
267}
268
269#[derive(Debug, ThisError)]
274pub enum PersistenceError {
275 #[error(transparent)]
276 Io(#[from] io::Error),
277
278 #[error(transparent)]
279 Json(#[from] serde_json::Error),
280
281 #[error(transparent)]
282 InvalidManifest(#[from] ManifestValidationError),
283
284 #[error(transparent)]
285 InvalidJournal(#[from] crate::journal::JournalValidationError),
286
287 #[error(transparent)]
288 Checksum(#[from] ArtifactChecksumError),
289
290 #[error("manifest backup id {manifest} does not match journal backup id {journal}")]
291 BackupIdMismatch { manifest: String, journal: String },
292
293 #[error("journal artifact {canister_id} snapshot {snapshot_id} is not durable")]
294 NonDurableArtifact {
295 canister_id: String,
296 snapshot_id: String,
297 },
298
299 #[error("manifest member {canister_id} snapshot {snapshot_id} has no journal artifact")]
300 MissingJournalArtifact {
301 canister_id: String,
302 snapshot_id: String,
303 },
304
305 #[error("journal artifact {canister_id} snapshot {snapshot_id} is not declared in manifest")]
306 UnexpectedJournalArtifact {
307 canister_id: String,
308 snapshot_id: String,
309 },
310
311 #[error(
312 "manifest checksum for {canister_id} snapshot {snapshot_id} does not match journal checksum"
313 )]
314 ManifestJournalChecksumMismatch {
315 canister_id: String,
316 snapshot_id: String,
317 manifest: String,
318 journal: String,
319 },
320
321 #[error(
322 "manifest artifact path for {canister_id} snapshot {snapshot_id} does not match journal artifact path"
323 )]
324 ManifestJournalArtifactPathMismatch {
325 canister_id: String,
326 snapshot_id: String,
327 manifest: String,
328 journal: String,
329 },
330
331 #[error("manifest topology receipt {field} does not match journal topology receipt")]
332 ManifestJournalTopologyReceiptMismatch {
333 field: String,
334 manifest: String,
335 journal: Option<String>,
336 },
337
338 #[error("artifact path does not exist: {0}")]
339 MissingArtifact(String),
340}
341
342fn inspect_layout(
344 manifest: &FleetBackupManifest,
345 journal: &DownloadJournal,
346) -> BackupInspectionReport {
347 let journal_report = journal.resume_report();
348 let journal_artifacts = journal
349 .artifacts
350 .iter()
351 .map(|entry| (artifact_key(&entry.canister_id, &entry.snapshot_id), entry))
352 .collect::<BTreeMap<_, _>>();
353 let manifest_artifacts = manifest
354 .fleet
355 .members
356 .iter()
357 .map(|member| {
358 (
359 artifact_key(&member.canister_id, &member.source_snapshot.snapshot_id),
360 member,
361 )
362 })
363 .collect::<BTreeMap<_, _>>();
364
365 let mut matched_artifacts = 0;
366 let mut missing_journal_artifacts = Vec::new();
367 let mut path_mismatches = Vec::new();
368 let mut checksum_mismatches = Vec::new();
369
370 for (key, member) in &manifest_artifacts {
371 let Some(entry) = journal_artifacts.get(key) else {
372 missing_journal_artifacts.push(artifact_reference(key));
373 continue;
374 };
375
376 matched_artifacts += 1;
377 if member.source_snapshot.artifact_path != entry.artifact_path {
378 path_mismatches.push(ArtifactPathMismatch {
379 canister_id: key.0.clone(),
380 snapshot_id: key.1.clone(),
381 manifest: member.source_snapshot.artifact_path.clone(),
382 journal: entry.artifact_path.clone(),
383 });
384 }
385
386 if let (Some(manifest_hash), Some(journal_hash)) = (
387 member.source_snapshot.checksum.as_deref(),
388 entry.checksum.as_deref(),
389 ) && manifest_hash != journal_hash
390 {
391 checksum_mismatches.push(ArtifactChecksumMismatch {
392 canister_id: key.0.clone(),
393 snapshot_id: key.1.clone(),
394 manifest: manifest_hash.to_string(),
395 journal: journal_hash.to_string(),
396 });
397 }
398 }
399
400 let unexpected_journal_artifacts = journal_artifacts
401 .keys()
402 .filter(|key| !manifest_artifacts.contains_key(*key))
403 .map(artifact_reference)
404 .collect::<Vec<_>>();
405 let topology_receipt_mismatches = topology_receipt_mismatches(manifest, journal);
406 let topology_receipts_match = topology_receipt_mismatches.is_empty();
407 let backup_id_matches = manifest.backup_id == journal.backup_id;
408 let ready_for_verify = backup_id_matches
409 && topology_receipts_match
410 && journal_report.is_complete
411 && missing_journal_artifacts.is_empty()
412 && unexpected_journal_artifacts.is_empty()
413 && path_mismatches.is_empty()
414 && checksum_mismatches.is_empty();
415
416 BackupInspectionReport {
417 backup_id: manifest.backup_id.clone(),
418 manifest_backup_id: manifest.backup_id.clone(),
419 journal_backup_id: journal.backup_id.clone(),
420 backup_id_matches,
421 journal_complete: journal_report.is_complete,
422 ready_for_verify,
423 manifest_members: manifest.fleet.members.len(),
424 journal_artifacts: journal.artifacts.len(),
425 matched_artifacts,
426 topology_receipt_mismatches,
427 missing_journal_artifacts,
428 unexpected_journal_artifacts,
429 path_mismatches,
430 checksum_mismatches,
431 }
432}
433
434fn provenance_report(
436 manifest: &FleetBackupManifest,
437 journal: &DownloadJournal,
438) -> BackupProvenanceReport {
439 let journal_artifacts = journal
440 .artifacts
441 .iter()
442 .map(|entry| (artifact_key(&entry.canister_id, &entry.snapshot_id), entry))
443 .collect::<BTreeMap<_, _>>();
444 let topology_receipt_mismatches = topology_receipt_mismatches(manifest, journal);
445 let topology_receipts_match = topology_receipt_mismatches.is_empty();
446
447 BackupProvenanceReport {
448 backup_id: manifest.backup_id.clone(),
449 manifest_backup_id: manifest.backup_id.clone(),
450 journal_backup_id: journal.backup_id.clone(),
451 backup_id_matches: manifest.backup_id == journal.backup_id,
452 manifest_version: manifest.manifest_version,
453 journal_version: journal.journal_version,
454 created_at: manifest.created_at.clone(),
455 tool_name: manifest.tool.name.clone(),
456 tool_version: manifest.tool.version.clone(),
457 source_environment: manifest.source.environment.clone(),
458 source_root_canister: manifest.source.root_canister.clone(),
459 topology_hash_algorithm: manifest.fleet.topology_hash_algorithm.clone(),
460 topology_hash_input: manifest.fleet.topology_hash_input.clone(),
461 discovery_topology_hash: manifest.fleet.discovery_topology_hash.clone(),
462 pre_snapshot_topology_hash: manifest.fleet.pre_snapshot_topology_hash.clone(),
463 accepted_topology_hash: manifest.fleet.topology_hash.clone(),
464 journal_discovery_topology_hash: journal.discovery_topology_hash.clone(),
465 journal_pre_snapshot_topology_hash: journal.pre_snapshot_topology_hash.clone(),
466 topology_receipts_match,
467 topology_receipt_mismatches,
468 backup_unit_count: manifest.consistency.backup_units.len(),
469 member_count: manifest.fleet.members.len(),
470 consistency_mode: consistency_mode_name(&manifest.consistency.mode).to_string(),
471 backup_units: manifest
472 .consistency
473 .backup_units
474 .iter()
475 .map(|unit| BackupUnitProvenance {
476 unit_id: unit.unit_id.clone(),
477 kind: backup_unit_kind_name(&unit.kind).to_string(),
478 roles: unit.roles.clone(),
479 consistency_reason: unit.consistency_reason.clone(),
480 dependency_closure: unit.dependency_closure.clone(),
481 topology_validation: unit.topology_validation.clone(),
482 quiescence_strategy: unit.quiescence_strategy.clone(),
483 })
484 .collect(),
485 members: manifest
486 .fleet
487 .members
488 .iter()
489 .map(|member| {
490 let journal_entry = journal_artifacts.get(&artifact_key(
491 &member.canister_id,
492 &member.source_snapshot.snapshot_id,
493 ));
494
495 MemberSnapshotProvenance {
496 canister_id: member.canister_id.clone(),
497 role: member.role.clone(),
498 parent_canister_id: member.parent_canister_id.clone(),
499 subnet_canister_id: member.subnet_canister_id.clone(),
500 identity_mode: identity_mode_name(&member.identity_mode).to_string(),
501 restore_group: member.restore_group,
502 verification_class: member.verification_class.clone(),
503 verification_checks: member.verification_checks.len(),
504 snapshot_id: member.source_snapshot.snapshot_id.clone(),
505 module_hash: member.source_snapshot.module_hash.clone(),
506 wasm_hash: member.source_snapshot.wasm_hash.clone(),
507 code_version: member.source_snapshot.code_version.clone(),
508 artifact_path: member.source_snapshot.artifact_path.clone(),
509 checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
510 manifest_checksum: member.source_snapshot.checksum.clone(),
511 journal_state: journal_entry
512 .map(|entry| artifact_state_name(entry.state).to_string()),
513 journal_checksum: journal_entry.and_then(|entry| entry.checksum.clone()),
514 journal_updated_at: journal_entry.map(|entry| entry.updated_at.clone()),
515 }
516 })
517 .collect(),
518 }
519}
520
521fn verify_layout_integrity(
523 layout: &BackupLayout,
524 manifest: &FleetBackupManifest,
525 journal: &DownloadJournal,
526) -> Result<BackupIntegrityReport, PersistenceError> {
527 if manifest.backup_id != journal.backup_id {
528 return Err(PersistenceError::BackupIdMismatch {
529 manifest: manifest.backup_id.clone(),
530 journal: journal.backup_id.clone(),
531 });
532 }
533
534 if let Some(mismatch) = topology_receipt_mismatches(manifest, journal)
535 .into_iter()
536 .next()
537 {
538 return Err(PersistenceError::ManifestJournalTopologyReceiptMismatch {
539 field: mismatch.field,
540 manifest: mismatch.manifest,
541 journal: mismatch.journal,
542 });
543 }
544
545 let expected_artifacts = manifest
546 .fleet
547 .members
548 .iter()
549 .map(|member| {
550 (
551 member.canister_id.as_str(),
552 member.source_snapshot.snapshot_id.as_str(),
553 )
554 })
555 .collect::<BTreeSet<_>>();
556 for entry in &journal.artifacts {
557 if !expected_artifacts.contains(&(entry.canister_id.as_str(), entry.snapshot_id.as_str())) {
558 return Err(PersistenceError::UnexpectedJournalArtifact {
559 canister_id: entry.canister_id.clone(),
560 snapshot_id: entry.snapshot_id.clone(),
561 });
562 }
563 }
564
565 let mut artifacts = Vec::with_capacity(journal.artifacts.len());
566 for member in &manifest.fleet.members {
567 let Some(entry) = journal.artifacts.iter().find(|entry| {
568 entry.canister_id == member.canister_id
569 && entry.snapshot_id == member.source_snapshot.snapshot_id
570 }) else {
571 return Err(PersistenceError::MissingJournalArtifact {
572 canister_id: member.canister_id.clone(),
573 snapshot_id: member.source_snapshot.snapshot_id.clone(),
574 });
575 };
576
577 if entry.state != ArtifactState::Durable {
578 return Err(PersistenceError::NonDurableArtifact {
579 canister_id: entry.canister_id.clone(),
580 snapshot_id: entry.snapshot_id.clone(),
581 });
582 }
583
584 let Some(expected_hash) = entry.checksum.as_deref() else {
585 unreachable!("validated durable journals must include checksums");
586 };
587 if member.source_snapshot.artifact_path != entry.artifact_path {
588 return Err(PersistenceError::ManifestJournalArtifactPathMismatch {
589 canister_id: entry.canister_id.clone(),
590 snapshot_id: entry.snapshot_id.clone(),
591 manifest: member.source_snapshot.artifact_path.clone(),
592 journal: entry.artifact_path.clone(),
593 });
594 }
595 if let Some(manifest_hash) = member.source_snapshot.checksum.as_deref()
596 && manifest_hash != expected_hash
597 {
598 return Err(PersistenceError::ManifestJournalChecksumMismatch {
599 canister_id: entry.canister_id.clone(),
600 snapshot_id: entry.snapshot_id.clone(),
601 manifest: manifest_hash.to_string(),
602 journal: expected_hash.to_string(),
603 });
604 }
605 let artifact_path = resolve_artifact_path(layout.root(), &entry.artifact_path);
606 if !artifact_path.exists() {
607 return Err(PersistenceError::MissingArtifact(
608 artifact_path.display().to_string(),
609 ));
610 }
611
612 ArtifactChecksum::from_path(&artifact_path)?.verify(expected_hash)?;
613 artifacts.push(ArtifactIntegrityReport {
614 canister_id: entry.canister_id.clone(),
615 snapshot_id: entry.snapshot_id.clone(),
616 artifact_path: artifact_path.display().to_string(),
617 checksum: expected_hash.to_string(),
618 });
619 }
620
621 Ok(BackupIntegrityReport {
622 backup_id: manifest.backup_id.clone(),
623 verified: true,
624 manifest_members: manifest.fleet.members.len(),
625 journal_artifacts: journal.artifacts.len(),
626 durable_artifacts: artifacts.len(),
627 artifacts,
628 })
629}
630
631fn artifact_key(canister_id: &str, snapshot_id: &str) -> (String, String) {
633 (canister_id.to_string(), snapshot_id.to_string())
634}
635
636fn artifact_reference(key: &(String, String)) -> ArtifactReference {
638 ArtifactReference {
639 canister_id: key.0.clone(),
640 snapshot_id: key.1.clone(),
641 }
642}
643
644fn topology_receipt_mismatches(
646 manifest: &FleetBackupManifest,
647 journal: &DownloadJournal,
648) -> Vec<TopologyReceiptMismatch> {
649 let mut mismatches = Vec::new();
650 record_topology_receipt_mismatch(
651 &mut mismatches,
652 "discovery_topology_hash",
653 &manifest.fleet.discovery_topology_hash,
654 journal.discovery_topology_hash.as_deref(),
655 );
656 record_topology_receipt_mismatch(
657 &mut mismatches,
658 "pre_snapshot_topology_hash",
659 &manifest.fleet.pre_snapshot_topology_hash,
660 journal.pre_snapshot_topology_hash.as_deref(),
661 );
662 mismatches
663}
664
665fn record_topology_receipt_mismatch(
667 mismatches: &mut Vec<TopologyReceiptMismatch>,
668 field: &str,
669 manifest: &str,
670 journal: Option<&str>,
671) {
672 if journal == Some(manifest) {
673 return;
674 }
675
676 mismatches.push(TopologyReceiptMismatch {
677 field: field.to_string(),
678 manifest: manifest.to_string(),
679 journal: journal.map(ToString::to_string),
680 });
681}
682
683const fn identity_mode_name(mode: &crate::manifest::IdentityMode) -> &'static str {
685 match mode {
686 crate::manifest::IdentityMode::Fixed => "fixed",
687 crate::manifest::IdentityMode::Relocatable => "relocatable",
688 }
689}
690
691const fn artifact_state_name(state: ArtifactState) -> &'static str {
693 match state {
694 ArtifactState::Created => "Created",
695 ArtifactState::Downloaded => "Downloaded",
696 ArtifactState::ChecksumVerified => "ChecksumVerified",
697 ArtifactState::Durable => "Durable",
698 }
699}
700
701fn resolve_artifact_path(root: &Path, artifact_path: &str) -> PathBuf {
703 let path = PathBuf::from(artifact_path);
704 if path.is_absolute() || path.exists() {
705 path
706 } else {
707 root.join(path)
708 }
709}
710
711fn write_json_atomic<T>(path: &Path, value: &T) -> Result<(), PersistenceError>
713where
714 T: Serialize,
715{
716 if let Some(parent) = path.parent() {
717 fs::create_dir_all(parent)?;
718 }
719
720 let tmp_path = temp_path_for(path);
721 let mut file = File::create(&tmp_path)?;
722 serde_json::to_writer_pretty(&mut file, value)?;
723 file.sync_all()?;
724 drop(file);
725
726 fs::rename(&tmp_path, path)?;
727
728 if let Some(parent) = path.parent() {
729 File::open(parent)?.sync_all()?;
730 }
731
732 Ok(())
733}
734
735fn read_json<T>(path: &Path) -> Result<T, PersistenceError>
737where
738 T: DeserializeOwned,
739{
740 let file = File::open(path)?;
741 Ok(serde_json::from_reader(file)?)
742}
743
744fn temp_path_for(path: &Path) -> PathBuf {
746 let mut file_name = path
747 .file_name()
748 .and_then(|name| name.to_str())
749 .unwrap_or("canic-backup")
750 .to_string();
751 file_name.push_str(".tmp");
752 path.with_file_name(file_name)
753}
754
755#[cfg(test)]
756mod tests;