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#[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 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 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 pub fn length(&self) -> u64 {
75 self.length
76 }
77
78 pub fn modified(&self) -> Option<SystemTime> {
80 self.modified
81 }
82
83 #[cfg(unix)]
85 pub fn device(&self) -> u64 {
86 self.device
87 }
88
89 #[cfg(unix)]
91 pub fn inode(&self) -> u64 {
92 self.inode
93 }
94}
95
96#[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#[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 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 pub fn expected_length(&self) -> u64 {
181 self.expected_length
182 }
183
184 pub fn committed_length(&self) -> u64 {
186 self.committed_length
187 }
188
189 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#[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#[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 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 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 pub fn path(&self) -> &Path {
325 &self.path
326 }
327
328 pub fn logical_name(&self) -> &str {
330 &self.logical_name
331 }
332
333 pub fn expected_length(&self) -> u64 {
335 self.expected_length
336 }
337
338 pub fn stat_fingerprint(&self) -> &FileStatFingerprint {
340 &self.stat_fingerprint
341 }
342
343 pub fn bound_file_id(&self) -> Option<FileId> {
345 self.bound_file_id
346 }
347
348 pub fn file_id(&self) -> Option<FileId> {
350 self.bound_file_id()
351 }
352
353 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 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 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 pub fn first_16k_md5(&self) -> Option<[u8; 16]> {
379 self.hash_16k()
380 }
381
382 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}