Skip to main content

takanawa_core/
metadata.rs

1use crc32fast::Hasher;
2
3use crate::bitmap::{ChunkBitmap, bitmap_len};
4use crate::chunk::{chunk_count_for, normalize_chunk_size};
5use crate::{HashConfig, Result, TakanawaError};
6
7/// Current on-disk part metadata version.
8pub const METADATA_VERSION: u16 = 2;
9
10const MAGIC: &[u8; 8] = b"TKNWPRT1";
11const HEADER_LEN: usize = 256;
12const ALIGNMENT: u64 = 4096;
13const ETAG_CAPACITY: usize = 512;
14const LAST_MODIFIED_CAPACITY: usize = 128;
15
16const VERSION_OFFSET: usize = 8;
17const HEADER_LEN_OFFSET: usize = 10;
18const CRC_OFFSET: usize = 12;
19const GENERATION_OFFSET: usize = 16;
20const CONTENT_LEN_OFFSET: usize = 24;
21const CHUNK_SIZE_OFFSET: usize = 32;
22const CHUNK_COUNT_OFFSET: usize = 40;
23const BITMAP_LEN_OFFSET: usize = 48;
24const URL_HASH_OFFSET: usize = 56;
25const HASH_KIND_OFFSET: usize = 88;
26const HASH_LEN_OFFSET: usize = 89;
27const EXPECTED_HASH_OFFSET: usize = 92;
28const EXPECTED_HASH_CAPACITY: usize = 64;
29const ETAG_LEN_OFFSET: usize = 156;
30const LAST_MODIFIED_LEN_OFFSET: usize = 158;
31const SLOT_SIZE_OFFSET: usize = 160;
32
33const V1_ETAG_LEN_OFFSET: usize = 124;
34const V1_LAST_MODIFIED_LEN_OFFSET: usize = 126;
35const V1_SLOT_SIZE_OFFSET: usize = 128;
36
37/// Remote resource properties captured before a download starts or resumes.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct RemoteInfo {
40    /// Total length of the remote resource in bytes.
41    pub content_len: u64,
42    /// Remote `ETag` validator, when supplied by the server.
43    pub etag: Option<String>,
44    /// Remote `Last-Modified` validator, when supplied by the server.
45    pub last_modified: Option<String>,
46}
47
48/// Persisted state for a resumable part file.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct PartMetadata {
51    /// Monotonic generation used to choose the newest metadata slot.
52    pub generation: u64,
53    /// SHA-256 hash of the source URL.
54    pub url_hash: [u8; 32],
55    /// Total content length in bytes.
56    pub content_len: u64,
57    /// Normalized chunk size in bytes.
58    pub chunk_size: u64,
59    /// Number of chunks in the resource.
60    pub chunk_count: u64,
61    /// Completion bitmap for all chunks.
62    pub bitmap: ChunkBitmap,
63    /// Stored `ETag` validator, when available.
64    pub etag: Option<String>,
65    /// Stored `Last-Modified` validator, when available.
66    pub last_modified: Option<String>,
67    /// Optional final-file hash verification configuration.
68    pub hash: HashConfig,
69}
70
71impl PartMetadata {
72    /// Creates metadata for a new part file.
73    ///
74    /// Passing `0` for `chunk_size` selects the default chunk size.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the chunk size is invalid, validator headers are too
79    /// large for the metadata slot, or the completion bitmap cannot be sized.
80    pub fn new(
81        url_hash: [u8; 32],
82        remote: &RemoteInfo,
83        chunk_size: u64,
84        hash: HashConfig,
85    ) -> Result<Self> {
86        validate_header_len(remote.etag.as_deref(), ETAG_CAPACITY, "ETag")?;
87        validate_header_len(
88            remote.last_modified.as_deref(),
89            LAST_MODIFIED_CAPACITY,
90            "Last-Modified",
91        )?;
92
93        let chunk_size = normalize_chunk_size(chunk_size)?;
94        let chunk_count = chunk_count_for(remote.content_len, chunk_size);
95        Ok(Self {
96            generation: 0,
97            url_hash,
98            content_len: remote.content_len,
99            chunk_size,
100            chunk_count,
101            bitmap: ChunkBitmap::new(chunk_count)?,
102            etag: remote.etag.clone(),
103            last_modified: remote.last_modified.clone(),
104            hash,
105        })
106    }
107
108    #[must_use]
109    /// Returns the number of completed chunks.
110    pub fn completed_chunks(&self) -> u64 {
111        self.bitmap.complete_count()
112    }
113
114    #[must_use]
115    /// Returns the number of bytes represented by completed chunks.
116    pub fn completed_bytes(&self) -> u64 {
117        if self.chunk_count == 0 {
118            return 0;
119        }
120
121        let full_chunks_before_last = self.chunk_count.saturating_sub(1);
122        let mut bytes = 0_u64;
123        for index in 0..full_chunks_before_last {
124            if self.bitmap.is_complete(index).unwrap_or(false) {
125                bytes += self.chunk_size;
126            }
127        }
128
129        if self
130            .bitmap
131            .is_complete(self.chunk_count - 1)
132            .unwrap_or(false)
133        {
134            let last_start = full_chunks_before_last * self.chunk_size;
135            bytes += self.content_len - last_start;
136        }
137
138        bytes
139    }
140
141    #[must_use]
142    /// Returns whether every chunk is complete.
143    pub fn all_complete(&self) -> bool {
144        self.bitmap.all_complete()
145    }
146
147    /// Verifies that existing metadata still matches a remote resource and caller configuration.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if the URL hash, content length, available validators,
152    /// chunk size, or hash configuration differ from the stored metadata.
153    pub fn ensure_compatible(
154        &self,
155        url_hash: [u8; 32],
156        remote: &RemoteInfo,
157        chunk_size: u64,
158        hash: HashConfig,
159    ) -> Result<()> {
160        let chunk_size = normalize_chunk_size(chunk_size)?;
161        if self.url_hash != url_hash {
162            return Err(TakanawaError::RemoteChanged(
163                "URL does not match part metadata".to_owned(),
164            ));
165        }
166        if self.content_len != remote.content_len {
167            return Err(TakanawaError::RemoteChanged(format!(
168                "content length changed from {} to {}",
169                self.content_len, remote.content_len
170            )));
171        }
172        if let (Some(stored), Some(current)) = (&self.etag, &remote.etag) {
173            if stored != current {
174                return Err(TakanawaError::RemoteChanged(format!(
175                    "ETag changed from {stored} to {current}"
176                )));
177            }
178        }
179        if let (Some(stored), Some(current)) = (&self.last_modified, &remote.last_modified) {
180            if stored != current {
181                return Err(TakanawaError::RemoteChanged(format!(
182                    "Last-Modified changed from {stored} to {current}"
183                )));
184            }
185        }
186        if self.chunk_size != chunk_size {
187            return Err(TakanawaError::RemoteChanged(format!(
188                "chunk size changed from {} to {chunk_size}",
189                self.chunk_size
190            )));
191        }
192        if self.hash != hash {
193            return Err(TakanawaError::RemoteChanged(
194                "hash configuration changed".to_owned(),
195            ));
196        }
197        Ok(())
198    }
199
200    /// Encodes this metadata into a fixed-size metadata slot.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if `slot_size` is invalid for this metadata, if header
205    /// values exceed their fixed capacities, or if supporting lengths overflow.
206    ///
207    /// # Panics
208    ///
209    /// Panics only if fixed metadata constants no longer fit their encoded
210    /// integer fields.
211    pub fn encode_slot(&self, slot_size: u64) -> Result<Vec<u8>> {
212        let slot_len = usize::try_from(slot_size)
213            .map_err(|_| TakanawaError::InvalidConfig("slot size overflow".to_owned()))?;
214        if slot_len < HEADER_LEN {
215            return Err(TakanawaError::InvalidConfig(
216                "slot size is smaller than metadata header".to_owned(),
217            ));
218        }
219
220        let bitmap_len = bitmap_len(self.chunk_count)?;
221        let expected_size = slot_size_for(self.content_len, self.chunk_size)?;
222        if expected_size != slot_size {
223            return Err(TakanawaError::InvalidConfig(format!(
224                "slot size mismatch: expected {expected_size}, got {slot_size}"
225            )));
226        }
227
228        validate_header_len(self.etag.as_deref(), ETAG_CAPACITY, "ETag")?;
229        validate_header_len(
230            self.last_modified.as_deref(),
231            LAST_MODIFIED_CAPACITY,
232            "Last-Modified",
233        )?;
234
235        let mut slot = vec![0; slot_len];
236        slot[0..8].copy_from_slice(MAGIC);
237        write_u16(&mut slot, VERSION_OFFSET, METADATA_VERSION);
238        write_u16(
239            &mut slot,
240            HEADER_LEN_OFFSET,
241            u16::try_from(HEADER_LEN)
242                .expect("metadata header length is a fixed value that fits u16"),
243        );
244        write_u64(&mut slot, GENERATION_OFFSET, self.generation);
245        write_u64(&mut slot, CONTENT_LEN_OFFSET, self.content_len);
246        write_u64(&mut slot, CHUNK_SIZE_OFFSET, self.chunk_size);
247        write_u64(&mut slot, CHUNK_COUNT_OFFSET, self.chunk_count);
248        write_u64(
249            &mut slot,
250            BITMAP_LEN_OFFSET,
251            u64::try_from(bitmap_len).expect("bitmap length fits in u64"),
252        );
253        slot[URL_HASH_OFFSET..URL_HASH_OFFSET + 32].copy_from_slice(&self.url_hash);
254        slot[HASH_KIND_OFFSET] = u8::from(self.hash.kind());
255        if let Some(hash) = self.hash.expected_bytes() {
256            slot[HASH_LEN_OFFSET] =
257                u8::try_from(hash.len()).expect("supported hash lengths fit in one byte");
258            slot[EXPECTED_HASH_OFFSET..EXPECTED_HASH_OFFSET + hash.len()].copy_from_slice(&hash);
259        }
260        write_u16(
261            &mut slot,
262            ETAG_LEN_OFFSET,
263            u16::try_from(self.etag.as_deref().map_or(0, str::len))
264                .expect("ETag length was validated to fit u16"),
265        );
266        write_u16(
267            &mut slot,
268            LAST_MODIFIED_LEN_OFFSET,
269            u16::try_from(self.last_modified.as_deref().map_or(0, str::len))
270                .expect("Last-Modified length was validated to fit u16"),
271        );
272        write_u64(&mut slot, SLOT_SIZE_OFFSET, slot_size);
273
274        let mut cursor = HEADER_LEN;
275        slot[cursor..cursor + bitmap_len].copy_from_slice(self.bitmap.as_bytes());
276        cursor += bitmap_len;
277        write_fixed_string(
278            &mut slot[cursor..cursor + ETAG_CAPACITY],
279            self.etag.as_deref(),
280        );
281        cursor += ETAG_CAPACITY;
282        write_fixed_string(
283            &mut slot[cursor..cursor + LAST_MODIFIED_CAPACITY],
284            self.last_modified.as_deref(),
285        );
286
287        let crc = checksum_slot(&slot);
288        write_u32(&mut slot, CRC_OFFSET, crc);
289        Ok(slot)
290    }
291
292    /// Decodes metadata from a fixed-size metadata slot.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if the slot is too short, has an unsupported version,
297    /// fails CRC validation, has inconsistent lengths, or contains invalid
298    /// UTF-8/header/hash data.
299    pub fn decode_slot(slot: &[u8]) -> Result<Self> {
300        let decoded = decode_slot_header(slot)?;
301
302        let mut url_hash = [0; 32];
303        url_hash.copy_from_slice(&slot[URL_HASH_OFFSET..URL_HASH_OFFSET + 32]);
304
305        let hash = decode_hash(slot, decoded.offsets)?;
306
307        let etag_len = usize::from(read_u16(slot, decoded.offsets.etag_len)?);
308        let last_modified_len = usize::from(read_u16(slot, decoded.offsets.last_modified_len)?);
309        if etag_len > ETAG_CAPACITY || last_modified_len > LAST_MODIFIED_CAPACITY {
310            return Err(TakanawaError::PartCorrupt(
311                "stored header length exceeds fixed capacity".to_owned(),
312            ));
313        }
314
315        let mut cursor = HEADER_LEN;
316        let bitmap = ChunkBitmap::from_bytes(
317            decoded.chunk_count,
318            slot[cursor..cursor + decoded.bitmap_len].to_vec(),
319        )?;
320        cursor += decoded.bitmap_len;
321        let etag = decode_optional_string(&slot[cursor..cursor + ETAG_CAPACITY], etag_len)?;
322        cursor += ETAG_CAPACITY;
323        let last_modified = decode_optional_string(
324            &slot[cursor..cursor + LAST_MODIFIED_CAPACITY],
325            last_modified_len,
326        )?;
327
328        Ok(Self {
329            generation: decoded.generation,
330            url_hash,
331            content_len: decoded.content_len,
332            chunk_size: decoded.chunk_size,
333            chunk_count: decoded.chunk_count,
334            bitmap,
335            etag,
336            last_modified,
337            hash,
338        })
339    }
340}
341
342#[derive(Debug, Clone, Copy)]
343struct MetadataOffsets {
344    etag_len: usize,
345    last_modified_len: usize,
346    slot_size: usize,
347    expected_hash_capacity: usize,
348}
349
350impl MetadataOffsets {
351    const fn for_version(version: u16) -> Self {
352        if version == 1 {
353            Self {
354                etag_len: V1_ETAG_LEN_OFFSET,
355                last_modified_len: V1_LAST_MODIFIED_LEN_OFFSET,
356                slot_size: V1_SLOT_SIZE_OFFSET,
357                expected_hash_capacity: 32,
358            }
359        } else {
360            Self {
361                etag_len: ETAG_LEN_OFFSET,
362                last_modified_len: LAST_MODIFIED_LEN_OFFSET,
363                slot_size: SLOT_SIZE_OFFSET,
364                expected_hash_capacity: EXPECTED_HASH_CAPACITY,
365            }
366        }
367    }
368}
369
370struct DecodedSlotHeader {
371    offsets: MetadataOffsets,
372    generation: u64,
373    content_len: u64,
374    chunk_size: u64,
375    chunk_count: u64,
376    bitmap_len: usize,
377}
378
379fn decode_slot_header(slot: &[u8]) -> Result<DecodedSlotHeader> {
380    if slot.len() < HEADER_LEN {
381        return Err(TakanawaError::PartCorrupt(
382            "metadata slot is shorter than header".to_owned(),
383        ));
384    }
385    if &slot[0..8] != MAGIC {
386        return Err(TakanawaError::PartCorrupt(
387            "metadata magic mismatch".to_owned(),
388        ));
389    }
390
391    let version = read_u16(slot, VERSION_OFFSET)?;
392    if version != 1 && version != METADATA_VERSION {
393        return Err(TakanawaError::PartCorrupt(format!(
394            "unsupported metadata version {version}"
395        )));
396    }
397    let offsets = MetadataOffsets::for_version(version);
398    let header_len = usize::from(read_u16(slot, HEADER_LEN_OFFSET)?);
399    if header_len != HEADER_LEN {
400        return Err(TakanawaError::PartCorrupt(format!(
401            "unexpected metadata header length {header_len}"
402        )));
403    }
404
405    verify_slot_crc(slot)?;
406
407    let generation = read_u64(slot, GENERATION_OFFSET)?;
408    let content_len = read_u64(slot, CONTENT_LEN_OFFSET)?;
409    let chunk_size = read_u64(slot, CHUNK_SIZE_OFFSET)?;
410    let chunk_count = read_u64(slot, CHUNK_COUNT_OFFSET)?;
411    let bitmap_len = decode_bitmap_len(slot, chunk_count)?;
412    verify_slot_size(slot, offsets, content_len, chunk_size)?;
413
414    Ok(DecodedSlotHeader {
415        offsets,
416        generation,
417        content_len,
418        chunk_size,
419        chunk_count,
420        bitmap_len,
421    })
422}
423
424fn verify_slot_crc(slot: &[u8]) -> Result<()> {
425    let stored_crc = read_u32(slot, CRC_OFFSET)?;
426    let actual_crc = checksum_slot(slot);
427    if stored_crc != actual_crc {
428        return Err(TakanawaError::PartCorrupt(format!(
429            "metadata CRC mismatch: expected {stored_crc:#010x}, got {actual_crc:#010x}"
430        )));
431    }
432    Ok(())
433}
434
435fn decode_bitmap_len(slot: &[u8], chunk_count: u64) -> Result<usize> {
436    let bitmap_len = usize::try_from(read_u64(slot, BITMAP_LEN_OFFSET)?)
437        .map_err(|_| TakanawaError::PartCorrupt("bitmap length overflow".to_owned()))?;
438    let expected_bitmap_len = bitmap_len_for_decode(chunk_count)?;
439    if bitmap_len != expected_bitmap_len {
440        return Err(TakanawaError::PartCorrupt(format!(
441            "bitmap length mismatch: expected {expected_bitmap_len}, got {bitmap_len}"
442        )));
443    }
444    Ok(bitmap_len)
445}
446
447fn verify_slot_size(
448    slot: &[u8],
449    offsets: MetadataOffsets,
450    content_len: u64,
451    chunk_size: u64,
452) -> Result<()> {
453    let slot_size = read_u64(slot, offsets.slot_size)?;
454    let expected_slot_size = slot_size_for(content_len, chunk_size)?;
455    if expected_slot_size != slot_size {
456        return Err(TakanawaError::PartCorrupt(format!(
457            "slot size mismatch: expected {expected_slot_size}, got {slot_size}"
458        )));
459    }
460    if usize::try_from(slot_size).ok() != Some(slot.len()) {
461        return Err(TakanawaError::PartCorrupt(format!(
462            "slot buffer length mismatch: header says {slot_size}, buffer has {}",
463            slot.len()
464        )));
465    }
466    Ok(())
467}
468
469fn decode_hash(slot: &[u8], offsets: MetadataOffsets) -> Result<HashConfig> {
470    let hash_kind =
471        crate::HashKind::from_u32(u32::from(slot[HASH_KIND_OFFSET])).ok_or_else(|| {
472            TakanawaError::PartCorrupt(format!("unsupported hash kind: {}", slot[HASH_KIND_OFFSET]))
473        })?;
474    let hash_len = usize::from(slot[HASH_LEN_OFFSET]);
475    if hash_len > offsets.expected_hash_capacity {
476        return Err(TakanawaError::PartCorrupt(format!(
477            "hash length {hash_len} exceeds capacity {}",
478            offsets.expected_hash_capacity
479        )));
480    }
481
482    HashConfig::from_expected_bytes(
483        hash_kind,
484        &slot[EXPECTED_HASH_OFFSET..EXPECTED_HASH_OFFSET + hash_len],
485    )
486    .ok_or_else(|| {
487        TakanawaError::PartCorrupt(format!(
488            "unsupported hash kind/length: {}/{hash_len}",
489            slot[HASH_KIND_OFFSET]
490        ))
491    })
492}
493
494/// Returns the aligned metadata slot size for a content length and chunk size.
495///
496/// Passing `0` for `chunk_size` selects the default chunk size.
497///
498/// # Errors
499///
500/// Returns an error if the chunk size or bitmap length is invalid.
501pub fn slot_size_for(content_len: u64, chunk_size: u64) -> Result<u64> {
502    let chunk_size = normalize_chunk_size(chunk_size)?;
503    let chunk_count = chunk_count_for(content_len, chunk_size);
504    let bitmap_len = u64::try_from(bitmap_len(chunk_count)?)
505        .map_err(|_| TakanawaError::InvalidConfig("bitmap length overflow".to_owned()))?;
506    let raw = HEADER_LEN as u64 + bitmap_len + ETAG_CAPACITY as u64 + LAST_MODIFIED_CAPACITY as u64;
507    Ok(align_up(raw, ALIGNMENT))
508}
509
510fn bitmap_len_for_decode(chunk_count: u64) -> Result<usize> {
511    bitmap_len(chunk_count)
512}
513
514fn align_up(value: u64, alignment: u64) -> u64 {
515    value.div_ceil(alignment) * alignment
516}
517
518fn validate_header_len(value: Option<&str>, cap: usize, name: &str) -> Result<()> {
519    if let Some(value) = value {
520        if value.len() > cap {
521            return Err(TakanawaError::InvalidConfig(format!(
522                "{name} header is longer than {cap} bytes"
523            )));
524        }
525    }
526    Ok(())
527}
528
529fn write_fixed_string(dst: &mut [u8], value: Option<&str>) {
530    if let Some(value) = value {
531        dst[..value.len()].copy_from_slice(value.as_bytes());
532    }
533}
534
535fn decode_optional_string(bytes: &[u8], len: usize) -> Result<Option<String>> {
536    if len == 0 {
537        return Ok(None);
538    }
539    let value = std::str::from_utf8(&bytes[..len])
540        .map_err(|err| TakanawaError::PartCorrupt(format!("invalid stored UTF-8: {err}")))?;
541    Ok(Some(value.to_owned()))
542}
543
544fn checksum_slot(slot: &[u8]) -> u32 {
545    let mut hasher = Hasher::new();
546    hasher.update(&slot[..CRC_OFFSET]);
547    hasher.update(&[0, 0, 0, 0]);
548    hasher.update(&slot[CRC_OFFSET + 4..]);
549    hasher.finalize()
550}
551
552fn read_u16(bytes: &[u8], offset: usize) -> Result<u16> {
553    let mut data = [0; 2];
554    data.copy_from_slice(
555        bytes
556            .get(offset..offset + 2)
557            .ok_or_else(|| TakanawaError::PartCorrupt("short metadata read".to_owned()))?,
558    );
559    Ok(u16::from_le_bytes(data))
560}
561
562fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> {
563    let mut data = [0; 4];
564    data.copy_from_slice(
565        bytes
566            .get(offset..offset + 4)
567            .ok_or_else(|| TakanawaError::PartCorrupt("short metadata read".to_owned()))?,
568    );
569    Ok(u32::from_le_bytes(data))
570}
571
572fn read_u64(bytes: &[u8], offset: usize) -> Result<u64> {
573    let mut data = [0; 8];
574    data.copy_from_slice(
575        bytes
576            .get(offset..offset + 8)
577            .ok_or_else(|| TakanawaError::PartCorrupt("short metadata read".to_owned()))?,
578    );
579    Ok(u64::from_le_bytes(data))
580}
581
582fn write_u16(bytes: &mut [u8], offset: usize, value: u16) {
583    bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
584}
585
586fn write_u32(bytes: &mut [u8], offset: usize, value: u32) {
587    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
588}
589
590fn write_u64(bytes: &mut [u8], offset: usize, value: u64) {
591    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::hash_url;
598
599    #[test]
600    fn slot_round_trips() {
601        let remote = RemoteInfo {
602            content_len: 10,
603            etag: Some("abc".to_owned()),
604            last_modified: Some("today".to_owned()),
605        };
606        let mut meta = PartMetadata::new(
607            hash_url("https://example.test/file"),
608            &remote,
609            4,
610            HashConfig::None,
611        )
612        .unwrap();
613        meta.bitmap.mark_complete(1).unwrap();
614        meta.generation = 42;
615
616        let slot_size = slot_size_for(meta.content_len, meta.chunk_size).unwrap();
617        let slot = meta.encode_slot(slot_size).unwrap();
618        let decoded = PartMetadata::decode_slot(&slot).unwrap();
619
620        assert_eq!(decoded, meta);
621    }
622
623    #[test]
624    fn slot_round_trips_wide_hashes() {
625        let remote = RemoteInfo {
626            content_len: 10,
627            etag: None,
628            last_modified: None,
629        };
630        let hashes = [
631            HashConfig::Sha1([1; 20]),
632            HashConfig::Sha256([2; 32]),
633            HashConfig::Sha512([3; 64]),
634            HashConfig::Md5([4; 16]),
635            HashConfig::Crc32([5; 4]),
636        ];
637
638        for hash in hashes {
639            let meta =
640                PartMetadata::new(hash_url("https://example.test/file"), &remote, 4, hash).unwrap();
641            let slot_size = slot_size_for(meta.content_len, meta.chunk_size).unwrap();
642            let slot = meta.encode_slot(slot_size).unwrap();
643            let decoded = PartMetadata::decode_slot(&slot).unwrap();
644
645            assert_eq!(decoded.hash, hash);
646        }
647    }
648
649    #[test]
650    fn picks_up_crc_corruption() {
651        let remote = RemoteInfo {
652            content_len: 10,
653            etag: None,
654            last_modified: None,
655        };
656        let meta = PartMetadata::new(
657            hash_url("https://example.test/file"),
658            &remote,
659            4,
660            HashConfig::None,
661        )
662        .unwrap();
663        let slot_size = slot_size_for(meta.content_len, meta.chunk_size).unwrap();
664        let mut slot = meta.encode_slot(slot_size).unwrap();
665        let last = slot.len() - 1;
666        slot[last] ^= 1;
667
668        assert!(PartMetadata::decode_slot(&slot).is_err());
669    }
670
671    #[test]
672    fn rejects_changed_remote_validators() {
673        let remote = RemoteInfo {
674            content_len: 10,
675            etag: Some("etag-a".to_owned()),
676            last_modified: Some("date-a".to_owned()),
677        };
678        let meta = PartMetadata::new(
679            hash_url("https://example.test/file"),
680            &remote,
681            4,
682            HashConfig::None,
683        )
684        .unwrap();
685
686        let changed_etag = RemoteInfo {
687            etag: Some("etag-b".to_owned()),
688            ..remote.clone()
689        };
690        assert!(matches!(
691            meta.ensure_compatible(
692                hash_url("https://example.test/file"),
693                &changed_etag,
694                4,
695                HashConfig::None,
696            ),
697            Err(TakanawaError::RemoteChanged(_))
698        ));
699
700        let changed_last_modified = RemoteInfo {
701            last_modified: Some("date-b".to_owned()),
702            ..remote.clone()
703        };
704        assert!(matches!(
705            meta.ensure_compatible(
706                hash_url("https://example.test/file"),
707                &changed_last_modified,
708                4,
709                HashConfig::None,
710            ),
711            Err(TakanawaError::RemoteChanged(_))
712        ));
713    }
714
715    #[test]
716    fn skips_missing_remote_validator_checks() {
717        let remote = RemoteInfo {
718            content_len: 10,
719            etag: Some("etag-a".to_owned()),
720            last_modified: Some("date-a".to_owned()),
721        };
722        let meta = PartMetadata::new(
723            hash_url("https://example.test/file"),
724            &remote,
725            4,
726            HashConfig::None,
727        )
728        .unwrap();
729        let missing_validators = RemoteInfo {
730            content_len: 10,
731            etag: None,
732            last_modified: None,
733        };
734
735        meta.ensure_compatible(
736            hash_url("https://example.test/file"),
737            &missing_validators,
738            4,
739            HashConfig::None,
740        )
741        .unwrap();
742    }
743}