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
7pub 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#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct RemoteInfo {
40 pub content_len: u64,
42 pub etag: Option<String>,
44 pub last_modified: Option<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct PartMetadata {
51 pub generation: u64,
53 pub url_hash: [u8; 32],
55 pub content_len: u64,
57 pub chunk_size: u64,
59 pub chunk_count: u64,
61 pub bitmap: ChunkBitmap,
63 pub etag: Option<String>,
65 pub last_modified: Option<String>,
67 pub hash: HashConfig,
69}
70
71impl PartMetadata {
72 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 pub fn completed_chunks(&self) -> u64 {
111 self.bitmap.complete_count()
112 }
113
114 #[must_use]
115 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 pub fn all_complete(&self) -> bool {
144 self.bitmap.all_complete()
145 }
146
147 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 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 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
494pub 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}