Skip to main content

quilt_rs/
checksum.rs

1//! This module contains helpers and structs for creating and managing checkums.
2
3use multihash::Multihash;
4use serde::Deserialize;
5use serde::Serialize;
6use std::fmt;
7use std::path::Path;
8use std::path::PathBuf;
9
10use crate::Res;
11use crate::error::ChecksumError;
12use crate::io::remote::HostChecksums;
13use crate::io::remote::HostConfig;
14use crate::io::storage::Storage;
15use crate::manifest::ManifestRow;
16
17mod crc64nvme;
18mod hash;
19mod remote;
20mod sha256;
21mod sha256_chunked;
22
23pub use crc64nvme::Crc64Hash;
24pub use crc64nvme::MULTIHASH_CRC64_NVME;
25pub use hash::Hash;
26pub use remote::hash_sha256_checksum;
27pub use sha256::MULTIHASH_SHA256;
28pub use sha256::Sha256Hash;
29pub use sha256_chunked::MULTIHASH_SHA256_CHUNKED;
30pub use sha256_chunked::Sha256ChunkedHash;
31pub(crate) use sha256_chunked::chunksize_and_parts;
32
33/// Type-safe container for object's checksum using struct types
34/// You can convert it to or from `Multihash<256>`.
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(untagged)]
37pub enum ObjectHash {
38    /// Legacy SHA256 checksum
39    Sha256(Sha256Hash),
40    /// Chunked SHA256 checksum
41    Sha256Chunked(Sha256ChunkedHash),
42    /// CRC64-NVMe checksum
43    Crc64(Crc64Hash),
44}
45
46impl Default for ObjectHash {
47    fn default() -> Self {
48        ObjectHash::Crc64(Crc64Hash::default())
49    }
50}
51
52/// Refresh hash for a file using the same algorithm as the reference row
53/// Returns None if hash hasn't changed, Some(ManifestRow) if it has changed
54pub async fn refresh_hash(
55    storage: &impl Storage,
56    path: &PathBuf,
57    row: ManifestRow,
58) -> Res<Option<ManifestRow>> {
59    let file = storage.open_file(path).await?;
60    let file_metadata = file.metadata().await?;
61    let size = file_metadata.len();
62
63    let computed_hash = match &row.hash {
64        ObjectHash::Crc64(_) => Crc64Hash::from_file(file).await?.into(),
65        ObjectHash::Sha256(_) => Sha256Hash::from_file(file).await?.into(),
66        ObjectHash::Sha256Chunked(_) => Sha256ChunkedHash::from_file(file).await?.into(),
67    };
68
69    Ok((computed_hash != row.hash).then(|| ManifestRow {
70        hash: computed_hash,
71        size,
72        ..row
73    }))
74}
75
76/// Calculate hash for a file using the algorithm specified by host config
77pub async fn calculate_hash(
78    storage: &impl Storage,
79    path: &Path,
80    logical_key: &Path,
81    host_config: &HostConfig,
82) -> Res<ManifestRow> {
83    let file = storage.open_file(path).await?;
84    let file_metadata = file.metadata().await?;
85    let size = file_metadata.len();
86
87    let hash = match host_config.checksums {
88        HostChecksums::Crc64 => Crc64Hash::from_file(file).await?.into(),
89        HostChecksums::Sha256Chunked => Sha256ChunkedHash::from_file(file).await?.into(),
90    };
91
92    Ok(ManifestRow {
93        logical_key: logical_key.to_path_buf(),
94        physical_key: format!("file://{}", path.display()),
95        size,
96        hash,
97        ..ManifestRow::default()
98    })
99}
100
101/// Verify hash for a file and optionally recalculate with host's preferred algorithm
102/// Returns None if hash hasn't changed, Some(ManifestRow) if it has changed
103pub async fn verify_hash(
104    storage: &impl Storage,
105    path: &PathBuf,
106    row: ManifestRow,
107    host_config: &HostConfig,
108) -> Res<Option<ManifestRow>> {
109    if let Some(modified) = refresh_hash(storage, path, row).await? {
110        // File has changed, check if we need to recalculate with host's preferred algorithm
111        if modified.hash.algorithm() == host_config.checksums.algorithm_code() {
112            // Already using the correct algorithm, no need to recalculate
113            Ok(Some(modified))
114        } else {
115            // Need to recalculate with host's preferred algorithm
116            let calculated_row =
117                calculate_hash(storage, path, &modified.logical_key, host_config).await?;
118            Ok(Some(calculated_row))
119        }
120    } else {
121        Ok(None)
122    }
123}
124
125impl TryFrom<Multihash<256>> for ObjectHash {
126    type Error = crate::Error;
127
128    fn try_from(multihash: Multihash<256>) -> Result<Self, Self::Error> {
129        match multihash.code() {
130            MULTIHASH_SHA256 => Ok(ObjectHash::Sha256(Sha256Hash::try_from(multihash)?)),
131            MULTIHASH_SHA256_CHUNKED => Ok(ObjectHash::Sha256Chunked(Sha256ChunkedHash::try_from(
132                multihash,
133            )?)),
134            MULTIHASH_CRC64_NVME => Ok(ObjectHash::Crc64(Crc64Hash::try_from(multihash)?)),
135            _ => Err(crate::Error::Checksum(ChecksumError::InvalidMultihash(
136                format!("Unsupported multihash code: {:#06x}", multihash.code()),
137            ))),
138        }
139    }
140}
141
142impl From<ObjectHash> for Multihash<256> {
143    fn from(object_hash: ObjectHash) -> Self {
144        match object_hash {
145            ObjectHash::Sha256(hash) => hash.into(),
146            ObjectHash::Sha256Chunked(hash) => hash.into(),
147            ObjectHash::Crc64(hash) => hash.into(),
148        }
149    }
150}
151
152impl From<Sha256Hash> for ObjectHash {
153    fn from(hash: Sha256Hash) -> Self {
154        ObjectHash::Sha256(hash)
155    }
156}
157
158impl From<Sha256ChunkedHash> for ObjectHash {
159    fn from(hash: Sha256ChunkedHash) -> Self {
160        ObjectHash::Sha256Chunked(hash)
161    }
162}
163
164impl From<Crc64Hash> for ObjectHash {
165    fn from(hash: Crc64Hash) -> Self {
166        ObjectHash::Crc64(hash)
167    }
168}
169
170impl ObjectHash {
171    /// Get the inner multihash
172    pub fn multihash(&self) -> &Multihash<256> {
173        match self {
174            ObjectHash::Sha256(hash) => hash.multihash(),
175            ObjectHash::Sha256Chunked(hash) => hash.multihash(),
176            ObjectHash::Crc64(hash) => hash.multihash(),
177        }
178    }
179
180    /// Get the algorithm code
181    pub fn algorithm(&self) -> u64 {
182        match self {
183            ObjectHash::Sha256(hash) => hash.algorithm(),
184            ObjectHash::Sha256Chunked(hash) => hash.algorithm(),
185            ObjectHash::Crc64(hash) => hash.algorithm(),
186        }
187    }
188
189    /// Get the digest bytes
190    pub fn digest(&self) -> &[u8] {
191        match self {
192            ObjectHash::Sha256(hash) => hash.digest(),
193            ObjectHash::Sha256Chunked(hash) => hash.digest(),
194            ObjectHash::Crc64(hash) => hash.digest(),
195        }
196    }
197}
198
199impl fmt::Display for ObjectHash {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        match self {
202            ObjectHash::Sha256(hash) => hash.fmt(f),
203            ObjectHash::Sha256Chunked(hash) => hash.fmt(f),
204            ObjectHash::Crc64(hash) => hash.fmt(f),
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    use test_log::test;
214
215    use std::path::Path;
216
217    use aws_sdk_s3::primitives::ByteStream;
218
219    use crate::Res;
220    use crate::io::storage::LocalStorage;
221    use crate::io::storage::Storage;
222    use crate::io::storage::StorageExt;
223    use crate::io::storage::mocks::MockStorage;
224
225    #[test]
226    fn test_conversion_errors() {
227        // Create a SHA256 hash and try to convert it to SHA256Chunked (should fail)
228        let sha256_hash = multihash::Multihash::wrap(MULTIHASH_SHA256, b"test").unwrap();
229        let result = Sha256ChunkedHash::try_from(sha256_hash);
230        assert!(result.is_err());
231        assert!(
232            result
233                .unwrap_err()
234                .to_string()
235                .contains("Expected SHA256 chunked hash")
236        );
237
238        // Create a SHA256Chunked hash and try to convert it to SHA256 (should fail)
239        let sha256_chunked_hash =
240            multihash::Multihash::wrap(MULTIHASH_SHA256_CHUNKED, b"test").unwrap();
241        let result = Sha256Hash::try_from(sha256_chunked_hash);
242        assert!(result.is_err());
243        assert!(
244            result
245                .unwrap_err()
246                .to_string()
247                .contains("Expected SHA256 hash")
248        );
249    }
250
251    #[test]
252    fn test_object_hash_display() -> Res {
253        // Test SHA256 display (hex format)
254        let object_hash = ObjectHash::Sha256(Sha256Hash::try_from("deadbeef")?);
255        assert_eq!(object_hash.to_string(), "deadbeef");
256
257        // Test SHA256Chunked display (base64 format)
258        let object_hash = ObjectHash::Sha256Chunked(Sha256ChunkedHash::try_from("Zm9vYmFy")?);
259        assert_eq!(object_hash.to_string(), "Zm9vYmFy");
260
261        // Test CRC64 display (base64 format)
262        let object_hash = ObjectHash::Crc64(Crc64Hash::try_from("aGVsbG8gd29ybGQ=")?);
263        assert_eq!(object_hash.to_string(), "aGVsbG8gd29ybGQ=");
264
265        Ok(())
266    }
267
268    #[test]
269    fn test_object_hash_conversions() -> Res {
270        // Test SHA256 conversion
271        let sha256_multihash = multihash::Multihash::wrap(MULTIHASH_SHA256, b"test_data").unwrap();
272        let object_hash = ObjectHash::try_from(sha256_multihash)?;
273        let back_to_multihash: Multihash<256> = object_hash.clone().into();
274        assert_eq!(sha256_multihash, back_to_multihash);
275        assert_eq!(object_hash.algorithm(), MULTIHASH_SHA256);
276
277        // Test SHA256Chunked conversion
278        let sha256_chunked_multihash =
279            multihash::Multihash::wrap(MULTIHASH_SHA256_CHUNKED, b"test_data").unwrap();
280        let object_hash = ObjectHash::try_from(sha256_chunked_multihash)?;
281        let back_to_multihash: Multihash<256> = object_hash.clone().into();
282        assert_eq!(sha256_chunked_multihash, back_to_multihash);
283        assert_eq!(object_hash.algorithm(), MULTIHASH_SHA256_CHUNKED);
284
285        // Test CRC64 conversion
286        let crc64_multihash =
287            multihash::Multihash::wrap(MULTIHASH_CRC64_NVME, b"test_data").unwrap();
288        let object_hash = ObjectHash::try_from(crc64_multihash)?;
289        let back_to_multihash: Multihash<256> = object_hash.clone().into();
290        assert_eq!(crc64_multihash, back_to_multihash);
291        assert_eq!(object_hash.algorithm(), MULTIHASH_CRC64_NVME);
292
293        let invalid_multihash = multihash::Multihash::wrap(0x9999, b"invalid_data").unwrap();
294        let result = ObjectHash::try_from(invalid_multihash);
295
296        assert!(result.is_err());
297        assert!(
298            result
299                .unwrap_err()
300                .to_string()
301                .contains("Unsupported multihash code: 0x9999")
302        );
303
304        Ok(())
305    }
306
307    #[test]
308    fn test_object_hash_from_individual_types() -> Res {
309        // Test from individual hash types
310        let sha256_hash =
311            Sha256Hash::try_from(multihash::Multihash::wrap(MULTIHASH_SHA256, b"test").unwrap())?;
312        let object_hash: ObjectHash = sha256_hash.clone().into();
313        assert_eq!(object_hash.algorithm(), MULTIHASH_SHA256);
314
315        let sha256_chunked_hash = Sha256ChunkedHash::try_from(
316            multihash::Multihash::wrap(MULTIHASH_SHA256_CHUNKED, b"test").unwrap(),
317        )?;
318        let object_hash: ObjectHash = sha256_chunked_hash.clone().into();
319        assert_eq!(object_hash.algorithm(), MULTIHASH_SHA256_CHUNKED);
320
321        let crc64_hash = Crc64Hash::try_from(
322            multihash::Multihash::wrap(MULTIHASH_CRC64_NVME, b"test").unwrap(),
323        )?;
324        let object_hash: ObjectHash = crc64_hash.clone().into();
325        assert_eq!(object_hash.algorithm(), MULTIHASH_CRC64_NVME);
326
327        Ok(())
328    }
329
330    #[test]
331    fn test_object_hash_serde() -> Res {
332        // Test SHA256 serde
333        let sha256_hash = Sha256Hash::try_from(
334            multihash::Multihash::wrap(MULTIHASH_SHA256, b"test_data").unwrap(),
335        )?;
336        let object_hash = ObjectHash::Sha256(sha256_hash);
337        let serialized = serde_json::to_string(&object_hash)?;
338        let deserialized: ObjectHash = serde_json::from_str(&serialized)?;
339        assert_eq!(object_hash, deserialized);
340
341        // Test SHA256Chunked serde
342        let sha256_chunked_hash = Sha256ChunkedHash::try_from(
343            multihash::Multihash::wrap(MULTIHASH_SHA256_CHUNKED, b"test_data").unwrap(),
344        )?;
345        let object_hash = ObjectHash::Sha256Chunked(sha256_chunked_hash);
346        let serialized = serde_json::to_string(&object_hash)?;
347        let deserialized: ObjectHash = serde_json::from_str(&serialized)?;
348        assert_eq!(object_hash, deserialized);
349
350        // Test CRC64 serde
351        let crc64_hash = Crc64Hash::try_from(
352            multihash::Multihash::wrap(MULTIHASH_CRC64_NVME, b"test_data").unwrap(),
353        )?;
354        let object_hash = ObjectHash::Crc64(crc64_hash);
355        let serialized = serde_json::to_string(&object_hash)?;
356        let deserialized: ObjectHash = serde_json::from_str(&serialized)?;
357        assert_eq!(object_hash, deserialized);
358
359        Ok(())
360    }
361
362    #[test]
363    fn test_object_hash_json_format_translation() -> Res {
364        // Test SHA256 JSON format translation
365        let sha256_json = r#"{"type":"SHA256","value":"7465737464617461000000000000000000000000000000000000000000000000"}"#;
366        let object_hash: ObjectHash = serde_json::from_str(sha256_json)?;
367        match object_hash {
368            ObjectHash::Sha256(hash) => {
369                assert_eq!(hash.algorithm(), MULTIHASH_SHA256);
370                assert_eq!(
371                    hex::encode(hash.digest()),
372                    "7465737464617461000000000000000000000000000000000000000000000000"
373                );
374            }
375            _ => {
376                return Err(crate::Error::Checksum(ChecksumError::InvalidMultihash(
377                    "Expected ObjectHash::Sha256 variant".to_string(),
378                )));
379            }
380        }
381
382        // Test SHA256Chunked JSON format translation
383        let sha256_chunked_json =
384            r#"{"type":"sha2-256-chunked","value":"dGVzdGRhdGEAAAAAAAAAAAAAAAAAAAAA"}"#;
385        let object_hash: ObjectHash = serde_json::from_str(sha256_chunked_json)?;
386        match object_hash {
387            ObjectHash::Sha256Chunked(hash) => {
388                assert_eq!(hash.algorithm(), MULTIHASH_SHA256_CHUNKED);
389                assert_eq!(
390                    &multibase::encode(multibase::Base::Base64Pad, hash.digest())[1..],
391                    "dGVzdGRhdGEAAAAAAAAAAAAAAAAAAAAA"
392                );
393            }
394            _ => {
395                return Err(crate::Error::Checksum(ChecksumError::InvalidMultihash(
396                    "Expected ObjectHash::Sha256Chunked variant".to_string(),
397                )));
398            }
399        }
400
401        // Test CRC64 JSON format translation
402        let crc64_json = r#"{"type":"CRC64NVME","value":"dGVzdGRhdGEAAAAAAAAAAAAAAAAAAAAA"}"#;
403        let object_hash: ObjectHash = serde_json::from_str(crc64_json)?;
404        match object_hash {
405            ObjectHash::Crc64(hash) => {
406                assert_eq!(hash.algorithm(), MULTIHASH_CRC64_NVME);
407                assert_eq!(
408                    &multibase::encode(multibase::Base::Base64Pad, hash.digest())[1..],
409                    "dGVzdGRhdGEAAAAAAAAAAAAAAAAAAAAA"
410                );
411            }
412            _ => {
413                return Err(crate::Error::Checksum(ChecksumError::InvalidMultihash(
414                    "Expected ObjectHash::Crc64 variant".to_string(),
415                )));
416            }
417        }
418
419        // Test that serialization produces the correct format
420        let hex_bytes =
421            hex::decode("7465737464617461000000000000000000000000000000000000000000000000")
422                .map_err(|e| ChecksumError::InvalidMultihash(e.to_string()))?;
423        let sha256_hash =
424            Sha256Hash::try_from(multihash::Multihash::wrap(MULTIHASH_SHA256, &hex_bytes)?)?;
425        let object_hash = ObjectHash::Sha256(sha256_hash);
426        let serialized = serde_json::to_string(&object_hash)?;
427        assert!(serialized.contains("\"type\":\"SHA256\""));
428        assert!(serialized.contains(
429            "\"value\":\"7465737464617461000000000000000000000000000000000000000000000000\""
430        ));
431
432        Ok(())
433    }
434
435    #[test]
436    fn test_object_hash_json_invalid_type() {
437        // Test invalid type field
438        let invalid_json = r#"{"type":"UNKNOWN","value":"deadbeef"}"#;
439        let result: Result<ObjectHash, _> = serde_json::from_str(invalid_json);
440        assert!(result.is_err());
441
442        // Test mismatched type/encoding
443        let mismatched_json = r#"{"type":"SHA256","value":"dGVzdA=="}"#; // base64 in SHA256 field
444        let result: Result<ObjectHash, _> = serde_json::from_str(mismatched_json);
445        assert!(result.is_err());
446    }
447
448    #[test(tokio::test)]
449    async fn test_hash_trait_polymorphism() -> Res {
450        let storage = MockStorage::default();
451        let test_data = b"test data for Hash trait";
452        let test_path = Path::new("hash_trait_test.txt");
453        storage
454            .write_byte_stream(test_path, ByteStream::from_static(test_data))
455            .await?;
456
457        // Test Hash trait implementation and consistent from_file signatures
458        let file = storage.open_file(test_path).await?;
459        let sha256_hash: Sha256Hash = <Sha256Hash as Hash>::from_file(file).await?;
460
461        let file = storage.open_file(test_path).await?;
462        let sha256_chunked_hash = <Sha256ChunkedHash as Hash>::from_file(file).await?;
463
464        let file = storage.open_file(test_path).await?;
465        let crc64_hash = <Crc64Hash as Hash>::from_file(file).await?;
466
467        // Test Hash trait methods
468        assert_eq!(sha256_hash.algorithm(), MULTIHASH_SHA256);
469        assert_eq!(sha256_chunked_hash.algorithm(), MULTIHASH_SHA256_CHUNKED);
470        assert_eq!(crc64_hash.algorithm(), MULTIHASH_CRC64_NVME);
471
472        assert!(!sha256_hash.digest().is_empty());
473        assert!(!sha256_chunked_hash.digest().is_empty());
474        assert!(!crc64_hash.digest().is_empty());
475
476        // Test trait object polymorphism
477        fn check_hash_trait<T: Hash>(hash: &T) -> u64 {
478            hash.algorithm()
479        }
480
481        assert_eq!(check_hash_trait(&sha256_hash), MULTIHASH_SHA256);
482        assert_eq!(
483            check_hash_trait(&sha256_chunked_hash),
484            MULTIHASH_SHA256_CHUNKED
485        );
486        assert_eq!(check_hash_trait(&crc64_hash), MULTIHASH_CRC64_NVME);
487
488        Ok(())
489    }
490
491    #[test(tokio::test)]
492    async fn test_refresh_hash_unchanged_file() -> Res {
493        let storage = MockStorage::default();
494        let file_content = b"anything";
495        let file_path = Path::new("foo");
496        storage
497            .write_byte_stream(file_path, ByteStream::from_static(file_content))
498            .await?;
499
500        let file = storage.open_file(file_path).await?;
501        let hash: Multihash<256> = Sha256Hash::from_file(file).await?.into();
502
503        let manifest_row = ManifestRow {
504            logical_key: PathBuf::from("bar"),
505            hash: hash.try_into()?,
506            size: file_content.len() as u64,
507            ..ManifestRow::default()
508        };
509        let result = refresh_hash(&storage, &file_path.to_path_buf(), manifest_row).await?;
510        assert!(result.is_none(), "Unchanged file should return None");
511
512        Ok(())
513    }
514
515    #[test(tokio::test)]
516    async fn test_refresh_hash_changed_file() -> Res {
517        let storage = MockStorage::default();
518        let file_content = b"anything";
519        let file_path = Path::new("foo");
520        storage
521            .write_byte_stream(file_path, ByteStream::from_static(file_content))
522            .await?;
523
524        // Calculate the actual hash first
525        let file = storage.open_file(file_path).await?;
526        let hash: Multihash<256> = Sha256Hash::from_file(file).await?.into();
527
528        // Test with wrong hash - should return updated ManifestRow
529        let wrong_hash = Multihash::wrap(MULTIHASH_SHA256, b"wrong_hash_data")?;
530        let test_manifest_row = ManifestRow {
531            logical_key: PathBuf::from("bar"),
532            hash: wrong_hash.try_into()?,
533            size: 999, // Wrong size to test that refresh_hash updates it
534            ..ManifestRow::default()
535        };
536        let result = refresh_hash(&storage, &file_path.to_path_buf(), test_manifest_row).await?;
537        let refreshed_row = result.expect("Changed hash should return Some");
538        assert_eq!(refreshed_row.hash, hash.try_into()?);
539        assert_eq!(refreshed_row.size, file_content.len() as u64);
540
541        Ok(())
542    }
543
544    #[test(tokio::test)]
545    async fn test_refresh_hash_unknown_algorithm() -> Res {
546        let storage = MockStorage::default();
547        let file_content = b"anything";
548        let file_path = Path::new("foo");
549        storage
550            .write_byte_stream(file_path, ByteStream::from_static(file_content))
551            .await?;
552
553        // Since ObjectHash now validates hash types, we cannot create invalid hashes
554        // This test is no longer relevant as the type system prevents invalid hash codes
555        // Let's test a valid case instead - using a valid hash should work
556        let valid_hash = Crc64Hash::default().into();
557        let manifest_row = ManifestRow {
558            logical_key: PathBuf::from("bar"),
559            hash: valid_hash,
560            size: file_content.len() as u64,
561            ..ManifestRow::default()
562        };
563
564        let result = refresh_hash(&storage, &file_path.to_path_buf(), manifest_row).await;
565        // With valid hash, this should work (though it might return Some due to different file content)
566        assert!(result.is_ok());
567
568        Ok(())
569    }
570
571    #[test(tokio::test)]
572    async fn test_calculate_hash_crc64() -> Res {
573        let storage = LocalStorage::default();
574
575        let file_path = Path::new("fixtures/user-settings.mkfg");
576        let host_config = HostConfig::default_crc64();
577        let logical_key = PathBuf::from("foo");
578
579        let row = calculate_hash(&storage, file_path, &logical_key, &host_config).await?;
580
581        assert_eq!(row.hash.algorithm(), MULTIHASH_CRC64_NVME);
582        assert_eq!(row.logical_key, logical_key);
583
584        assert_eq!(row.size, storage.read_bytes(file_path).await?.len() as u64);
585        assert_eq!(row.hash.to_string(), "LZmmpqbBItw=");
586
587        Ok(())
588    }
589
590    #[test(tokio::test)]
591    async fn test_calculate_hash_sha256_chunked() -> Res {
592        let storage = MockStorage::default();
593
594        let host_config = HostConfig::default_sha256_chunked();
595
596        let file_content = crate::fixtures::objects::less_than_8mb();
597        let file_path = Path::new("foo");
598        storage
599            .write_byte_stream(file_path, ByteStream::from_static(file_content))
600            .await?;
601
602        let logical_key = PathBuf::from("bar");
603
604        let row = calculate_hash(&storage, file_path, &logical_key, &host_config).await?;
605
606        assert_eq!(row.hash.algorithm(), MULTIHASH_SHA256_CHUNKED);
607        assert_eq!(row.logical_key, logical_key);
608        assert_eq!(row.size, file_content.len() as u64);
609        assert_eq!(
610            row.hash.to_string(),
611            crate::fixtures::objects::LESS_THAN_8MB_HASH_B64
612        );
613
614        Ok(())
615    }
616
617    #[test(tokio::test)]
618    async fn test_verify_hash_crc64() -> Res {
619        let storage = MockStorage::default();
620        let local_storage = LocalStorage::default();
621
622        // Create file with initial content
623        let test_path = Path::new("foo");
624        let initial_content = b"lorem ipsum";
625        storage
626            .write_byte_stream(test_path, ByteStream::from_static(initial_content))
627            .await?;
628
629        let sha256_host_config = HostConfig::default_sha256_chunked();
630        let crc64_host_config = HostConfig::default_crc64();
631        let logical_key = PathBuf::from("bar");
632
633        let manifest_row =
634            calculate_hash(&storage, test_path, &logical_key, &sha256_host_config).await?;
635
636        assert!(
637            verify_hash(
638                &storage,
639                &test_path.to_path_buf(),
640                manifest_row.clone(),
641                &crc64_host_config,
642            )
643            .await?
644            .is_none(),
645            "Unchanged file should return None, even when algorithms don't match"
646        );
647
648        let fixture_path = Path::new("fixtures/user-settings.mkfg");
649        let fixture_content = local_storage.read_byte_stream(fixture_path).await?;
650        storage
651            .write_byte_stream(test_path, fixture_content)
652            .await?;
653
654        let result = verify_hash(
655            &storage,
656            &test_path.to_path_buf(),
657            manifest_row,
658            &crc64_host_config,
659        )
660        .await?;
661        assert!(result.is_some(), "Modified file should return Some");
662
663        let modified_row = result.unwrap();
664        assert_eq!(modified_row.hash.algorithm(), MULTIHASH_CRC64_NVME);
665
666        assert_eq!(modified_row.hash.to_string(), "LZmmpqbBItw=");
667
668        Ok(())
669    }
670
671    #[test(tokio::test)]
672    async fn test_verify_hash_sha256_chunked() -> Res {
673        let storage = MockStorage::default();
674
675        // Create file with initial content
676        let test_path = Path::new("foo");
677        let initial_content = b"lorem ipsum";
678        storage
679            .write_byte_stream(test_path, ByteStream::from_static(initial_content))
680            .await?;
681
682        let sha256_host_config = HostConfig::default_sha256_chunked();
683        let crc64_host_config = HostConfig::default_crc64();
684        let logical_key = PathBuf::from("bar");
685
686        let manifest_row =
687            calculate_hash(&storage, test_path, &logical_key, &crc64_host_config).await?;
688
689        assert!(
690            verify_hash(
691                &storage,
692                &test_path.to_path_buf(),
693                manifest_row.clone(),
694                &sha256_host_config,
695            )
696            .await?
697            .is_none(),
698            "Unchanged file should return None, even when algorithms don't match"
699        );
700
701        // Now rewrite file with less_than_8mb fixture content
702        let fixture_content = crate::fixtures::objects::less_than_8mb();
703        storage
704            .write_byte_stream(test_path, ByteStream::from_static(fixture_content))
705            .await?;
706
707        let result = verify_hash(
708            &storage,
709            &test_path.to_path_buf(),
710            manifest_row,
711            &sha256_host_config,
712        )
713        .await?;
714        assert!(result.is_some(), "Modified file should return Some");
715
716        let modified_row = result.unwrap();
717        assert_eq!(modified_row.hash.algorithm(), MULTIHASH_SHA256_CHUNKED);
718        assert_eq!(modified_row.size, fixture_content.len() as u64);
719
720        assert_eq!(
721            modified_row.hash.to_string(),
722            crate::fixtures::objects::LESS_THAN_8MB_HASH_B64
723        );
724
725        Ok(())
726    }
727}