Skip to main content

par2_rs/
evidence.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6use thiserror::Error;
7
8use crate::types::FileId;
9
10/// A snapshot of the filesystem identity and size of a committed file.
11///
12/// This is captured while constructing [`CommittedFileEvidence`] and can be
13/// compared by callers before trusting that the committed path still names the
14/// file whose checksums were recorded.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct FileStatFingerprint {
17    length: u64,
18    modified: Option<SystemTime>,
19    #[cfg(unix)]
20    device: u64,
21    #[cfg(unix)]
22    inode: u64,
23}
24
25impl FileStatFingerprint {
26    fn capture(path: &Path) -> io::Result<Self> {
27        let metadata = fs::metadata(path)?;
28        Ok(Self::from_metadata(&metadata))
29    }
30
31    /// Fingerprint `path` exactly as this crate's stat gates compare it.
32    ///
33    /// Symlinks are not followed and only regular files fingerprint, so a path
34    /// that is (or became) a directory, a symlink or a device reads as `None`
35    /// rather than as a file — the same rule the carry gate applies, because
36    /// this *is* the function that gate calls.
37    ///
38    /// Callers building a [`crate::ScanCarry`] from their own verification pass
39    /// must capture the fingerprint at the moment they read the file's bytes,
40    /// not afterwards: the gate's whole guarantee is that the file the repair
41    /// reads is the file the fingerprint describes, and a fingerprint taken
42    /// late silently covers whatever changed in between.
43    ///
44    /// What this can prove is what `stat` can prove — length, mtime, and on
45    /// Unix device and inode. A same-length rewrite that also restores the
46    /// original mtime in place is invisible to it, which is why a repair that
47    /// consumes a carry re-checks the bytes themselves against their slice
48    /// checksums as it reads them.
49    pub fn capture_path(path: impl AsRef<Path>) -> Option<Self> {
50        fs::symlink_metadata(path.as_ref())
51            .ok()
52            .filter(|meta| meta.file_type().is_file())
53            .map(|meta| Self::from_metadata(&meta))
54    }
55
56    /// Build a fingerprint from metadata the caller already has. Callers that
57    /// need to distinguish "not a regular file" from "changed" must apply that
58    /// filter themselves; this records what the stat said, nothing more.
59    pub(crate) fn from_metadata(metadata: &fs::Metadata) -> Self {
60        #[cfg(unix)]
61        use std::os::unix::fs::MetadataExt;
62
63        Self {
64            length: metadata.len(),
65            modified: metadata.modified().ok(),
66            #[cfg(unix)]
67            device: metadata.dev(),
68            #[cfg(unix)]
69            inode: metadata.ino(),
70        }
71    }
72
73    /// File length recorded by the stat call.
74    pub fn length(&self) -> u64 {
75        self.length
76    }
77
78    /// Last-modified timestamp recorded by the stat call, when available.
79    pub fn modified(&self) -> Option<SystemTime> {
80        self.modified
81    }
82
83    /// Unix device number recorded by the stat call.
84    #[cfg(unix)]
85    pub fn device(&self) -> u64 {
86        self.device
87    }
88
89    /// Unix inode number recorded by the stat call.
90    #[cfg(unix)]
91    pub fn inode(&self) -> u64 {
92        self.inode
93    }
94}
95
96/// Why an aggregate contiguous-assembly claim cannot be trusted.
97#[derive(Debug, Clone, PartialEq, Eq, Error)]
98pub enum ContiguousAssemblyProofError {
99    #[error("committed length {committed_length} does not match expected length {expected_length}")]
100    ExpectedCommittedLengthMismatch {
101        expected_length: u64,
102        committed_length: u64,
103    },
104    #[error("covered length {covered_length} does not match expected length {expected_length}")]
105    ExpectedCoveredLengthMismatch {
106        expected_length: u64,
107        covered_length: u64,
108    },
109    #[error("contiguous assembly has coverage gaps")]
110    CoverageGaps,
111    #[error("contiguous assembly has overlapping parts")]
112    Overlaps,
113    #[error("contiguous assembly has mismatched duplicate parts")]
114    MismatchedDuplicates,
115    #[error("not all article CRCs were verified")]
116    UnverifiedArticleCrcs,
117}
118
119/// Validated aggregate evidence that a committed file was assembled
120/// contiguously from CRC-verified articles.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct ContiguousAssemblyProof {
123    expected_length: u64,
124    committed_length: u64,
125    covered_length: u64,
126}
127
128impl ContiguousAssemblyProof {
129    /// Validate and record an aggregate contiguous-assembly claim.
130    ///
131    /// All supplied lengths must agree, coverage must be gap-free and
132    /// non-overlapping, duplicate articles must agree, and every article CRC
133    /// must already have been verified.
134    pub fn try_new(
135        expected_length: u64,
136        committed_length: u64,
137        covered_length: u64,
138        has_gaps: bool,
139        has_overlaps: bool,
140        has_mismatched_duplicates: bool,
141        all_parts_crc_verified: bool,
142    ) -> Result<Self, ContiguousAssemblyProofError> {
143        if committed_length != expected_length {
144            return Err(
145                ContiguousAssemblyProofError::ExpectedCommittedLengthMismatch {
146                    expected_length,
147                    committed_length,
148                },
149            );
150        }
151        if covered_length != expected_length {
152            return Err(
153                ContiguousAssemblyProofError::ExpectedCoveredLengthMismatch {
154                    expected_length,
155                    covered_length,
156                },
157            );
158        }
159        if has_gaps {
160            return Err(ContiguousAssemblyProofError::CoverageGaps);
161        }
162        if has_overlaps {
163            return Err(ContiguousAssemblyProofError::Overlaps);
164        }
165        if has_mismatched_duplicates {
166            return Err(ContiguousAssemblyProofError::MismatchedDuplicates);
167        }
168        if !all_parts_crc_verified {
169            return Err(ContiguousAssemblyProofError::UnverifiedArticleCrcs);
170        }
171
172        Ok(Self {
173            expected_length,
174            committed_length,
175            covered_length,
176        })
177    }
178
179    /// The length expected from PAR2/file metadata.
180    pub fn expected_length(&self) -> u64 {
181        self.expected_length
182    }
183
184    /// The byte count committed to the assembled output.
185    pub fn committed_length(&self) -> u64 {
186        self.committed_length
187    }
188
189    /// The byte count covered by the verified input articles.
190    pub fn covered_length(&self) -> u64 {
191        self.covered_length
192    }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196enum EvidenceKind {
197    FullMd5([u8; 16]),
198    ContiguousAssembly {
199        crc32: u32,
200        hash_16k: [u8; 16],
201        proof: ContiguousAssemblyProof,
202    },
203}
204
205/// Errors while capturing evidence for a committed file.
206#[derive(Debug, Error)]
207pub enum EvidenceError {
208    #[error("failed to stat committed file {path}: {source}")]
209    Stat {
210        path: PathBuf,
211        #[source]
212        source: io::Error,
213    },
214    #[error(
215        "committed file length {actual_length} does not match expected length {expected_length}"
216    )]
217    LengthMismatch {
218        expected_length: u64,
219        actual_length: u64,
220    },
221    #[error(
222        "assembly proof expects length {proof_expected_length}, not evidence length {expected_length}"
223    )]
224    ProofExpectedLengthMismatch {
225        expected_length: u64,
226        proof_expected_length: u64,
227    },
228}
229
230/// Immutable evidence captured when an assembled file is committed.
231///
232/// The fields are intentionally private: every instance is created only after
233/// statting its path and checking its recorded size against the expected file
234/// length.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct CommittedFileEvidence {
237    path: PathBuf,
238    logical_name: String,
239    expected_length: u64,
240    stat_fingerprint: FileStatFingerprint,
241    bound_file_id: Option<FileId>,
242    kind: EvidenceKind,
243}
244
245impl CommittedFileEvidence {
246    /// Capture evidence based on a complete file MD5.
247    pub fn from_full_md5_path(
248        path: impl AsRef<Path>,
249        logical_name: impl Into<String>,
250        expected_length: u64,
251        md5: [u8; 16],
252        bound_file_id: Option<FileId>,
253    ) -> Result<Self, EvidenceError> {
254        Self::from_path(
255            path.as_ref(),
256            logical_name.into(),
257            expected_length,
258            bound_file_id,
259            EvidenceKind::FullMd5(md5),
260        )
261    }
262
263    /// Capture evidence based on a contiguous CRC-verified assembly.
264    pub fn from_contiguous_assembly_path(
265        path: impl AsRef<Path>,
266        logical_name: impl Into<String>,
267        expected_length: u64,
268        crc32: u32,
269        hash_16k: [u8; 16],
270        proof: ContiguousAssemblyProof,
271        bound_file_id: Option<FileId>,
272    ) -> Result<Self, EvidenceError> {
273        if proof.expected_length() != expected_length {
274            return Err(EvidenceError::ProofExpectedLengthMismatch {
275                expected_length,
276                proof_expected_length: proof.expected_length(),
277            });
278        }
279
280        Self::from_path(
281            path.as_ref(),
282            logical_name.into(),
283            expected_length,
284            bound_file_id,
285            EvidenceKind::ContiguousAssembly {
286                crc32,
287                hash_16k,
288                proof,
289            },
290        )
291    }
292
293    fn from_path(
294        path: &Path,
295        logical_name: String,
296        expected_length: u64,
297        bound_file_id: Option<FileId>,
298        kind: EvidenceKind,
299    ) -> Result<Self, EvidenceError> {
300        let path = path.to_path_buf();
301        let stat_fingerprint =
302            FileStatFingerprint::capture(&path).map_err(|source| EvidenceError::Stat {
303                path: path.clone(),
304                source,
305            })?;
306        if stat_fingerprint.length() != expected_length {
307            return Err(EvidenceError::LengthMismatch {
308                expected_length,
309                actual_length: stat_fingerprint.length(),
310            });
311        }
312
313        Ok(Self {
314            path,
315            logical_name,
316            expected_length,
317            stat_fingerprint,
318            bound_file_id,
319            kind,
320        })
321    }
322
323    /// Path statted while this evidence was captured.
324    pub fn path(&self) -> &Path {
325        &self.path
326    }
327
328    /// The logical output name associated with the path.
329    pub fn logical_name(&self) -> &str {
330        &self.logical_name
331    }
332
333    /// The expected unpadded file length.
334    pub fn expected_length(&self) -> u64 {
335        self.expected_length
336    }
337
338    /// Immutable stat fingerprint captured from [`Self::path`].
339    pub fn stat_fingerprint(&self) -> &FileStatFingerprint {
340        &self.stat_fingerprint
341    }
342
343    /// Optional PAR2 file ID explicitly bound by the caller.
344    pub fn bound_file_id(&self) -> Option<FileId> {
345        self.bound_file_id
346    }
347
348    /// Alias for [`Self::bound_file_id`].
349    pub fn file_id(&self) -> Option<FileId> {
350        self.bound_file_id()
351    }
352
353    /// Complete-file MD5 evidence, when that form was captured.
354    pub fn full_md5(&self) -> Option<[u8; 16]> {
355        match &self.kind {
356            EvidenceKind::FullMd5(md5) => Some(*md5),
357            EvidenceKind::ContiguousAssembly { .. } => None,
358        }
359    }
360
361    /// Complete-file CRC32 from contiguous-assembly evidence, if present.
362    pub fn assembly_crc32(&self) -> Option<u32> {
363        match &self.kind {
364            EvidenceKind::FullMd5(_) => None,
365            EvidenceKind::ContiguousAssembly { crc32, .. } => Some(*crc32),
366        }
367    }
368
369    /// MD5 of the first 16 KiB from contiguous-assembly evidence, if present.
370    pub fn hash_16k(&self) -> Option<[u8; 16]> {
371        match &self.kind {
372            EvidenceKind::FullMd5(_) => None,
373            EvidenceKind::ContiguousAssembly { hash_16k, .. } => Some(*hash_16k),
374        }
375    }
376
377    /// Alias for [`Self::hash_16k`].
378    pub fn first_16k_md5(&self) -> Option<[u8; 16]> {
379        self.hash_16k()
380    }
381
382    /// Contiguous-assembly proof, when this evidence was captured that way.
383    pub fn assembly_proof(&self) -> Option<&ContiguousAssemblyProof> {
384        match &self.kind {
385            EvidenceKind::FullMd5(_) => None,
386            EvidenceKind::ContiguousAssembly { proof, .. } => Some(proof),
387        }
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use tempfile::tempdir;
395
396    #[test]
397    fn proof_accepts_only_complete_crc_verified_contiguous_coverage() {
398        let proof = ContiguousAssemblyProof::try_new(12, 12, 12, false, false, false, true)
399            .expect("complete CRC-verified coverage should prove contiguity");
400
401        assert_eq!(proof.expected_length(), 12);
402        assert_eq!(proof.committed_length(), 12);
403        assert_eq!(proof.covered_length(), 12);
404    }
405
406    #[test]
407    fn proof_refuses_invalid_aggregate_evidence() {
408        assert!(matches!(
409            ContiguousAssemblyProof::try_new(12, 11, 12, false, false, false, true),
410            Err(ContiguousAssemblyProofError::ExpectedCommittedLengthMismatch { .. })
411        ));
412        assert!(matches!(
413            ContiguousAssemblyProof::try_new(12, 12, 11, false, false, false, true),
414            Err(ContiguousAssemblyProofError::ExpectedCoveredLengthMismatch { .. })
415        ));
416        assert!(matches!(
417            ContiguousAssemblyProof::try_new(12, 12, 12, true, false, false, true),
418            Err(ContiguousAssemblyProofError::CoverageGaps)
419        ));
420        assert!(matches!(
421            ContiguousAssemblyProof::try_new(12, 12, 12, false, true, false, true),
422            Err(ContiguousAssemblyProofError::Overlaps)
423        ));
424        assert!(matches!(
425            ContiguousAssemblyProof::try_new(12, 12, 12, false, false, true, true),
426            Err(ContiguousAssemblyProofError::MismatchedDuplicates)
427        ));
428        assert!(matches!(
429            ContiguousAssemblyProof::try_new(12, 12, 12, false, false, false, false),
430            Err(ContiguousAssemblyProofError::UnverifiedArticleCrcs)
431        ));
432    }
433
434    #[test]
435    fn full_md5_evidence_owns_path_and_stat_fingerprint() {
436        let dir = tempdir().unwrap();
437        let path = dir.path().join("complete.bin");
438        std::fs::write(&path, b"complete").unwrap();
439        let file_id = FileId::from_bytes([0x13; 16]);
440
441        let evidence = CommittedFileEvidence::from_full_md5_path(
442            &path,
443            "release/complete.bin",
444            8,
445            [0xA5; 16],
446            Some(file_id),
447        )
448        .unwrap();
449
450        assert_eq!(evidence.path(), path.as_path());
451        assert_eq!(evidence.logical_name(), "release/complete.bin");
452        assert_eq!(evidence.expected_length(), 8);
453        assert_eq!(evidence.stat_fingerprint().length(), 8);
454        assert_eq!(evidence.bound_file_id(), Some(file_id));
455        assert_eq!(evidence.file_id(), Some(file_id));
456        assert_eq!(evidence.full_md5(), Some([0xA5; 16]));
457        assert_eq!(evidence.assembly_crc32(), None);
458        assert_eq!(evidence.assembly_proof(), None);
459    }
460
461    #[test]
462    fn contiguous_assembly_evidence_requires_matching_proof_and_file_length() {
463        let dir = tempdir().unwrap();
464        let path = dir.path().join("assembled.bin");
465        std::fs::write(&path, b"assembled").unwrap();
466        let proof = ContiguousAssemblyProof::try_new(9, 9, 9, false, false, false, true).unwrap();
467
468        let evidence = CommittedFileEvidence::from_contiguous_assembly_path(
469            &path,
470            "assembled.bin",
471            9,
472            0xCAFE_BABE,
473            [0x5A; 16],
474            proof.clone(),
475            None,
476        )
477        .unwrap();
478        assert_eq!(evidence.full_md5(), None);
479        assert_eq!(evidence.assembly_crc32(), Some(0xCAFE_BABE));
480        assert_eq!(evidence.hash_16k(), Some([0x5A; 16]));
481        assert_eq!(evidence.first_16k_md5(), Some([0x5A; 16]));
482        assert_eq!(evidence.assembly_proof(), Some(&proof));
483
484        assert!(matches!(
485            CommittedFileEvidence::from_contiguous_assembly_path(
486                &path,
487                "assembled.bin",
488                8,
489                0,
490                [0; 16],
491                proof,
492                None,
493            ),
494            Err(EvidenceError::ProofExpectedLengthMismatch { .. })
495        ));
496        assert!(matches!(
497            CommittedFileEvidence::from_full_md5_path(&path, "assembled.bin", 8, [0; 16], None),
498            Err(EvidenceError::LengthMismatch { .. })
499        ));
500    }
501}