Skip to main content

wire/
provider_pack.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::{
3    collections::HashSet,
4    fs::{self, File, OpenOptions},
5    io::Write,
6    path::{Path, PathBuf},
7    sync::{Arc, Mutex},
8};
9
10use objects::store::{
11    ObjectStore,
12    pack::{PackContainerSpec, PackIndex, PackObjectId, PackReader, verify_container},
13};
14
15use crate::{
16    MAX_RECEIVED_PACK_SIZE, NativePackBundle, ProtocolError, Result, native_pack::unique_spool_dir,
17};
18
19const PACK_HEADER_LEN: usize = 16;
20const PACK_TRAILER_LEN: usize = 32;
21const PACK_SPEC: PackContainerSpec = PackContainerSpec {
22    magic: b"LMPK",
23    version: 4,
24};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct ProviderPackIndexEntry {
28    pub id: PackObjectId,
29    pub output_offset: u64,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ProviderPackExtent {
34    pub output_offset: u64,
35    pub length: u64,
36    pub digest: [u8; 32],
37    pub objects: Vec<ProviderPackIndexEntry>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ProviderPackManifest {
42    pub header: [u8; PACK_HEADER_LEN],
43    pub output_pack_length: u64,
44    pub extents: Vec<ProviderPackExtent>,
45}
46
47#[derive(Debug)]
48pub struct ProviderPackBundle {
49    pub pack: NativePackBundle,
50    pub trailer_digest: [u8; 32],
51}
52
53/// A bounded-memory positional writer for one validated provider pack plan.
54#[derive(Clone, Debug)]
55pub struct ProviderPackWriter {
56    file: Arc<File>,
57    ranges: Arc<Vec<(u64, u64)>>,
58    verified: Arc<Mutex<Vec<bool>>>,
59}
60
61/// A pre-sized provider pack spool that owns all partial transfer state.
62#[derive(Debug)]
63pub struct ProviderPackSpool {
64    dir: Option<PathBuf>,
65    pack_path: PathBuf,
66    index_path: PathBuf,
67    file: Arc<File>,
68    manifest: ProviderPackManifest,
69    verified: Arc<Mutex<Vec<bool>>>,
70}
71
72/// A completely covered and validated provider pack ready for atomic install.
73#[derive(Debug)]
74pub struct CompletedProviderPack {
75    dir: PathBuf,
76    pack_path: PathBuf,
77    index_path: PathBuf,
78    pub trailer_digest: [u8; 32],
79}
80
81impl ProviderPackSpool {
82    /// Create an exact-length spool and write the validated virtual pack header.
83    pub fn new_in(root: &Path, manifest: ProviderPackManifest) -> Result<Self> {
84        validate_manifest(&manifest)?;
85        let base = root.join("transfer-spool");
86        fs::create_dir_all(&base)?;
87        let dir = unique_spool_dir(&base)?;
88        let pack_path = dir.join("provider.pack");
89        let index_path = dir.join("provider.idx");
90        let create_result = (|| -> Result<File> {
91            let file = OpenOptions::new()
92                .read(true)
93                .write(true)
94                .create_new(true)
95                .open(&pack_path)?;
96            file.set_len(manifest.output_pack_length)?;
97            write_all_at(&file, &manifest.header, 0)?;
98            Ok(file)
99        })();
100        let file = match create_result {
101            Ok(file) => file,
102            Err(error) => {
103                let _ = fs::remove_dir_all(&dir);
104                return Err(error);
105            }
106        };
107        let verified = Arc::new(Mutex::new(vec![false; manifest.extents.len()]));
108        Ok(Self {
109            dir: Some(dir),
110            pack_path,
111            index_path,
112            file: Arc::new(file),
113            manifest,
114            verified,
115        })
116    }
117
118    /// Obtain a cloneable positional writer for concurrent extent streams.
119    pub fn writer(&self) -> ProviderPackWriter {
120        ProviderPackWriter {
121            file: Arc::clone(&self.file),
122            ranges: Arc::new(
123                self.manifest
124                    .extents
125                    .iter()
126                    .map(|extent| (extent.output_offset, extent.length))
127                    .collect(),
128            ),
129            verified: Arc::clone(&self.verified),
130        }
131    }
132
133    /// Finalize the trailer and index after every extent has verified exactly once.
134    pub fn finish(mut self) -> Result<CompletedProviderPack> {
135        let verified = self.verified.lock().map_err(|_| {
136            ProtocolError::InvalidState("provider spool verification lock poisoned".to_string())
137        })?;
138        if verified.iter().any(|complete| !complete) {
139            return Err(ProtocolError::InvalidState(
140                "provider spool does not have complete verified coverage".to_string(),
141            ));
142        }
143        drop(verified);
144
145        let body_end = self.manifest.output_pack_length - PACK_TRAILER_LEN as u64;
146        let trailer_digest = hash_file_prefix(&self.file, body_end)?;
147        write_all_at(&self.file, &trailer_digest, body_end)?;
148        objects::fs_atomic::sync_file(&self.file, &self.pack_path)?;
149        write_provider_index(&self.index_path, &self.manifest)?;
150
151        let reader = PackReader::open(
152            &self.pack_path,
153            &self.index_path,
154            self.dir.as_ref().ok_or_else(|| {
155                ProtocolError::InvalidState("provider spool directory is missing".into())
156            })?,
157        )?;
158        let expected_objects = self
159            .manifest
160            .extents
161            .iter()
162            .map(|extent| extent.objects.len())
163            .sum::<usize>();
164        if reader.object_count() != expected_objects {
165            return Err(ProtocolError::InvalidState(
166                "provider pack index does not match its manifest".to_string(),
167            ));
168        }
169        drop(reader);
170
171        let dir = self.dir.take().ok_or_else(|| {
172            ProtocolError::InvalidState("provider spool directory is missing".to_string())
173        })?;
174        Ok(CompletedProviderPack {
175            dir,
176            pack_path: self.pack_path.clone(),
177            index_path: self.index_path.clone(),
178            trailer_digest,
179        })
180    }
181}
182
183impl ProviderPackWriter {
184    /// Write one chunk at its manifest-assigned extent-relative position.
185    pub fn write_extent_chunk(
186        &self,
187        extent_index: usize,
188        relative_offset: u64,
189        data: &[u8],
190    ) -> Result<()> {
191        let (output_offset, extent_len) =
192            self.ranges.get(extent_index).copied().ok_or_else(|| {
193                ProtocolError::InvalidState("provider extent index is out of range".to_string())
194            })?;
195        let data_len = u64::try_from(data.len()).map_err(|_| {
196            ProtocolError::InvalidState("provider chunk length exceeds u64".to_string())
197        })?;
198        let relative_end = relative_offset.checked_add(data_len).ok_or_else(|| {
199            ProtocolError::InvalidState("provider extent write offset overflows".to_string())
200        })?;
201        if relative_end > extent_len {
202            return Err(ProtocolError::InvalidState(
203                "provider extent write exceeds its planned range".to_string(),
204            ));
205        }
206        let absolute_offset = output_offset.checked_add(relative_offset).ok_or_else(|| {
207            ProtocolError::InvalidState("provider spool write offset overflows".to_string())
208        })?;
209        write_all_at(&self.file, data, absolute_offset)
210    }
211
212    /// Rehash a retained prefix without retaining the extent body in memory.
213    pub fn hash_extent_prefix(
214        &self,
215        extent_index: usize,
216        prefix_len: u64,
217        hasher: &mut blake3::Hasher,
218    ) -> Result<()> {
219        let (output_offset, extent_len) =
220            self.ranges.get(extent_index).copied().ok_or_else(|| {
221                ProtocolError::InvalidState("provider extent index is out of range".to_string())
222            })?;
223        if prefix_len > extent_len {
224            return Err(ProtocolError::InvalidState(
225                "provider retained prefix exceeds its planned extent".to_string(),
226            ));
227        }
228        let mut buffer = [0_u8; 64 * 1024];
229        let mut read = 0_u64;
230        while read < prefix_len {
231            let remaining = prefix_len - read;
232            let length = usize::try_from(remaining.min(buffer.len() as u64)).map_err(|_| {
233                ProtocolError::InvalidState("provider prefix length exceeds usize".to_string())
234            })?;
235            let offset = output_offset.checked_add(read).ok_or_else(|| {
236                ProtocolError::InvalidState("provider prefix read offset overflows".to_string())
237            })?;
238            read_exact_at(&self.file, &mut buffer[..length], offset)?;
239            hasher.update(&buffer[..length]);
240            read += length as u64;
241        }
242        Ok(())
243    }
244
245    /// Mark one fully length- and digest-verified extent complete exactly once.
246    pub fn mark_verified(&self, extent_index: usize) -> Result<()> {
247        let mut verified = self.verified.lock().map_err(|_| {
248            ProtocolError::InvalidState("provider spool verification lock poisoned".to_string())
249        })?;
250        let complete = verified.get_mut(extent_index).ok_or_else(|| {
251            ProtocolError::InvalidState("provider extent index is out of range".to_string())
252        })?;
253        if *complete {
254            return Err(ProtocolError::InvalidState(
255                "provider extent completed more than once".to_string(),
256            ));
257        }
258        *complete = true;
259        Ok(())
260    }
261}
262
263impl CompletedProviderPack {
264    /// Validated native pack and index paths, retained by this completed spool.
265    pub fn artifact_paths(&self) -> (&Path, &Path) {
266        (&self.pack_path, &self.index_path)
267    }
268
269    /// Atomically install a fully validated provider pack into the object store.
270    pub fn install_into(
271        &mut self,
272        store: &impl ObjectStore,
273    ) -> Result<objects::store::pack::PackInventory> {
274        store
275            .install_pack_streaming(&self.pack_path, &self.index_path)
276            .map_err(ProtocolError::from)
277    }
278}
279
280impl Drop for ProviderPackSpool {
281    fn drop(&mut self) {
282        if let Some(dir) = self.dir.as_ref() {
283            let _ = fs::remove_dir_all(dir);
284        }
285    }
286}
287
288impl Drop for CompletedProviderPack {
289    fn drop(&mut self) {
290        let _ = fs::remove_dir_all(&self.dir);
291    }
292}
293
294fn write_provider_index(path: &Path, manifest: &ProviderPackManifest) -> Result<()> {
295    let mut index = PackIndex::new();
296    for extent in &manifest.extents {
297        for object in &extent.objects {
298            index.add(object.id, object.output_offset);
299        }
300    }
301    index.sort();
302    let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
303    file.write_all(&index.to_bytes())?;
304    file.flush()?;
305    objects::fs_atomic::sync_file(&file, path)?;
306    Ok(())
307}
308
309fn hash_file_prefix(file: &File, length: u64) -> Result<[u8; 32]> {
310    let mut hasher = blake3::Hasher::new();
311    let mut buffer = [0_u8; 64 * 1024];
312    let mut offset = 0_u64;
313    while offset < length {
314        let remaining = length - offset;
315        let read_len = usize::try_from(remaining.min(buffer.len() as u64)).map_err(|_| {
316            ProtocolError::InvalidState("provider pack hash length exceeds usize".to_string())
317        })?;
318        read_exact_at(file, &mut buffer[..read_len], offset)?;
319        hasher.update(&buffer[..read_len]);
320        offset += read_len as u64;
321    }
322    Ok(*hasher.finalize().as_bytes())
323}
324
325#[cfg(unix)]
326fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> Result<()> {
327    use std::os::unix::fs::FileExt;
328
329    while !data.is_empty() {
330        let written = file.write_at(data, offset)?;
331        if written == 0 {
332            return Err(ProtocolError::Io(std::io::Error::new(
333                std::io::ErrorKind::WriteZero,
334                "provider positional spool write returned zero",
335            )));
336        }
337        data = &data[written..];
338        offset = offset.checked_add(written as u64).ok_or_else(|| {
339            ProtocolError::InvalidState("provider positional write offset overflows".to_string())
340        })?;
341    }
342    Ok(())
343}
344
345#[cfg(windows)]
346fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> Result<()> {
347    use std::os::windows::fs::FileExt;
348
349    while !data.is_empty() {
350        let written = file.seek_write(data, offset)?;
351        if written == 0 {
352            return Err(ProtocolError::Io(std::io::Error::new(
353                std::io::ErrorKind::WriteZero,
354                "provider positional spool write returned zero",
355            )));
356        }
357        data = &data[written..];
358        offset = offset.checked_add(written as u64).ok_or_else(|| {
359            ProtocolError::InvalidState("provider positional write offset overflows".to_string())
360        })?;
361    }
362    Ok(())
363}
364
365#[cfg(unix)]
366fn read_exact_at(file: &File, mut data: &mut [u8], mut offset: u64) -> Result<()> {
367    use std::os::unix::fs::FileExt;
368
369    while !data.is_empty() {
370        let read = file.read_at(data, offset)?;
371        if read == 0 {
372            return Err(ProtocolError::Io(std::io::Error::new(
373                std::io::ErrorKind::UnexpectedEof,
374                "provider positional spool read ended early",
375            )));
376        }
377        data = &mut data[read..];
378        offset = offset.checked_add(read as u64).ok_or_else(|| {
379            ProtocolError::InvalidState("provider positional read offset overflows".to_string())
380        })?;
381    }
382    Ok(())
383}
384
385#[cfg(windows)]
386fn read_exact_at(file: &File, mut data: &mut [u8], mut offset: u64) -> Result<()> {
387    use std::os::windows::fs::FileExt;
388
389    while !data.is_empty() {
390        let read = file.seek_read(data, offset)?;
391        if read == 0 {
392            return Err(ProtocolError::Io(std::io::Error::new(
393                std::io::ErrorKind::UnexpectedEof,
394                "provider positional spool read ended early",
395            )));
396        }
397        data = &mut data[read..];
398        offset = offset.checked_add(read as u64).ok_or_else(|| {
399            ProtocolError::InvalidState("provider positional read offset overflows".to_string())
400        })?;
401    }
402    Ok(())
403}
404
405/// Assemble and verify one virtual native pack from provider extent bodies.
406///
407/// `extent_bodies` uses the same order as `manifest.extents`. The manifest may
408/// arrive in any order, but its output layout must cover every byte between the
409/// 16-byte header and 32-byte trailer exactly once.
410pub fn assemble_provider_pack(
411    manifest: &ProviderPackManifest,
412    extent_bodies: &[Vec<u8>],
413) -> Result<ProviderPackBundle> {
414    validate_manifest(manifest)?;
415    if extent_bodies.len() != manifest.extents.len() {
416        return Err(ProtocolError::InvalidState(format!(
417            "provider extent count mismatch: expected {}, got {}",
418            manifest.extents.len(),
419            extent_bodies.len()
420        )));
421    }
422
423    let mut order = (0..manifest.extents.len()).collect::<Vec<_>>();
424    order.sort_unstable_by_key(|index| manifest.extents[*index].output_offset);
425    let output_len = usize::try_from(manifest.output_pack_length).map_err(|_| {
426        ProtocolError::InvalidState("provider output pack exceeds this platform".to_string())
427    })?;
428    let mut pack_data = Vec::with_capacity(output_len);
429    pack_data.extend_from_slice(&manifest.header);
430    for index in order {
431        let extent = &manifest.extents[index];
432        let body = &extent_bodies[index];
433        let expected_len = usize::try_from(extent.length).map_err(|_| {
434            ProtocolError::InvalidState("provider extent exceeds this platform".to_string())
435        })?;
436        if body.len() != expected_len {
437            return Err(ProtocolError::InvalidState(format!(
438                "provider extent length mismatch: expected {}, got {}",
439                extent.length,
440                body.len()
441            )));
442        }
443        if blake3::hash(body).as_bytes() != &extent.digest {
444            return Err(ProtocolError::InvalidState(
445                "provider extent digest mismatch".to_string(),
446            ));
447        }
448        pack_data.extend_from_slice(body);
449    }
450
451    let trailer_digest = *blake3::hash(&pack_data).as_bytes();
452    pack_data.extend_from_slice(&trailer_digest);
453    if pack_data.len() != output_len {
454        return Err(ProtocolError::InvalidState(format!(
455            "provider output pack length mismatch: expected {}, got {}",
456            manifest.output_pack_length,
457            pack_data.len()
458        )));
459    }
460    verify_container(&pack_data, PACK_SPEC).map_err(ProtocolError::from)?;
461
462    let mut index = PackIndex::new();
463    for extent in &manifest.extents {
464        for object in &extent.objects {
465            index.add(object.id, object.output_offset);
466        }
467    }
468    index.sort();
469
470    Ok(ProviderPackBundle {
471        pack: NativePackBundle {
472            pack_data,
473            index_data: index.to_bytes(),
474        },
475        trailer_digest,
476    })
477}
478
479fn validate_manifest(manifest: &ProviderPackManifest) -> Result<()> {
480    if manifest.output_pack_length > MAX_RECEIVED_PACK_SIZE
481        || manifest.output_pack_length < (PACK_HEADER_LEN + PACK_TRAILER_LEN) as u64
482    {
483        return Err(ProtocolError::InvalidState(
484            "provider output pack length is invalid".to_string(),
485        ));
486    }
487    if &manifest.header[..4] != PACK_SPEC.magic
488        || u32::from_be_bytes(manifest.header[4..8].try_into().map_err(|_| {
489            ProtocolError::InvalidState("provider pack header is truncated".to_string())
490        })?) != PACK_SPEC.version
491    {
492        return Err(ProtocolError::InvalidState(
493            "provider pack header has invalid magic or version".to_string(),
494        ));
495    }
496    let object_count = u64::from_be_bytes(manifest.header[8..16].try_into().map_err(|_| {
497        ProtocolError::InvalidState("provider pack header is truncated".to_string())
498    })?);
499    let expected_body_end = manifest.output_pack_length - PACK_TRAILER_LEN as u64;
500    let mut order = manifest.extents.iter().collect::<Vec<_>>();
501    order.sort_unstable_by_key(|extent| extent.output_offset);
502    let mut next_offset = PACK_HEADER_LEN as u64;
503    let mut ids = HashSet::new();
504    let mut object_offsets = HashSet::new();
505    let mut actual_object_count = 0_u64;
506
507    for extent in order {
508        if extent.length == 0 || extent.output_offset != next_offset {
509            return Err(ProtocolError::InvalidState(
510                "provider extents do not exactly cover the virtual pack body".to_string(),
511            ));
512        }
513        next_offset = extent
514            .output_offset
515            .checked_add(extent.length)
516            .ok_or_else(|| {
517                ProtocolError::InvalidState("provider extent output range overflows".to_string())
518            })?;
519        if next_offset > expected_body_end {
520            return Err(ProtocolError::InvalidState(
521                "provider extent exceeds the virtual pack body".to_string(),
522            ));
523        }
524        for object in &extent.objects {
525            if object.output_offset < extent.output_offset
526                || object.output_offset >= next_offset
527                || !ids.insert(object.id)
528                || !object_offsets.insert(object.output_offset)
529            {
530                return Err(ProtocolError::InvalidState(
531                    "provider object index is outside its extent or duplicated".to_string(),
532                ));
533            }
534            actual_object_count = actual_object_count.checked_add(1).ok_or_else(|| {
535                ProtocolError::InvalidState("provider object count overflows".to_string())
536            })?;
537        }
538    }
539    if next_offset != expected_body_end || actual_object_count != object_count {
540        return Err(ProtocolError::InvalidState(
541            "provider manifest body or object count is incomplete".to_string(),
542        ));
543    }
544    Ok(())
545}
546
547#[cfg(test)]
548mod tests {
549    use objects::{
550        object::ContentHash,
551        store::{
552            CompressionConfig,
553            pack::{ObjectType, PackBuilder, PackIndex, PackObjectId},
554        },
555    };
556
557    use super::*;
558
559    fn source_pack() -> (Vec<u8>, Vec<u8>, Vec<PackObjectId>) {
560        let ids = vec![
561            PackObjectId::Hash(ContentHash::from_bytes([1; 32])),
562            PackObjectId::Hash(ContentHash::from_bytes([2; 32])),
563        ];
564        let mut builder = PackBuilder::new(CompressionConfig {
565            enabled: false,
566            ..CompressionConfig::default()
567        });
568        builder.add_id(ids[0], ObjectType::Blob, b"provider-one".to_vec());
569        builder.add_id(ids[1], ObjectType::Blob, b"provider-two".to_vec());
570        let (pack, index, _) = builder.build().unwrap();
571        (pack, index, ids)
572    }
573
574    fn split_manifest() -> (ProviderPackManifest, Vec<Vec<u8>>, Vec<u8>, Vec<u8>) {
575        let (pack, index, ids) = source_pack();
576        let parsed_index = PackIndex::from_bytes(&index).unwrap();
577        let first = parsed_index
578            .find(&ids[0])
579            .unwrap()
580            .expect("first fixture object indexed");
581        let second = parsed_index
582            .find(&ids[1])
583            .unwrap()
584            .expect("second fixture object indexed");
585        let (first_id, first_offset, second_id, second_offset) = if first < second {
586            (ids[0], first, ids[1], second)
587        } else {
588            (ids[1], second, ids[0], first)
589        };
590        let body_end = pack.len() - PACK_TRAILER_LEN;
591        let first_body = pack[first_offset as usize..second_offset as usize].to_vec();
592        let second_body = pack[second_offset as usize..body_end].to_vec();
593        let manifest = ProviderPackManifest {
594            header: pack[..PACK_HEADER_LEN].try_into().unwrap(),
595            output_pack_length: pack.len() as u64,
596            extents: vec![
597                ProviderPackExtent {
598                    output_offset: first_offset,
599                    length: first_body.len() as u64,
600                    digest: *blake3::hash(&first_body).as_bytes(),
601                    objects: vec![ProviderPackIndexEntry {
602                        id: first_id,
603                        output_offset: first_offset,
604                    }],
605                },
606                ProviderPackExtent {
607                    output_offset: second_offset,
608                    length: second_body.len() as u64,
609                    digest: *blake3::hash(&second_body).as_bytes(),
610                    objects: vec![ProviderPackIndexEntry {
611                        id: second_id,
612                        output_offset: second_offset,
613                    }],
614                },
615            ],
616        };
617        (manifest, vec![first_body, second_body], pack, index)
618    }
619
620    #[test]
621    fn provider_and_ordinary_pack_results_are_byte_identical() {
622        let (manifest, bodies, source_pack, source_index) = split_manifest();
623
624        let assembled = assemble_provider_pack(&manifest, &bodies).unwrap();
625
626        assert_eq!(assembled.pack.pack_data, source_pack);
627        assert_eq!(assembled.pack.index_data, source_index);
628        let source_digest = blake3::Hash::from_bytes(
629            source_pack[source_pack.len() - PACK_TRAILER_LEN..]
630                .try_into()
631                .unwrap(),
632        );
633        println!(
634            "byte_identical provider_digest={} weft_digest={} pack_bytes={} index_bytes={} identical=true",
635            blake3::Hash::from_bytes(assembled.trailer_digest),
636            source_digest,
637            source_pack.len(),
638            source_index.len(),
639        );
640    }
641
642    #[test]
643    fn digest_mismatch_never_produces_an_installable_pack() {
644        let (manifest, mut bodies, _, _) = split_manifest();
645        bodies[1][0] ^= 0xff;
646
647        let error = assemble_provider_pack(&manifest, &bodies).unwrap_err();
648
649        assert!(error.to_string().contains("digest mismatch"));
650    }
651
652    #[test]
653    fn manifest_gaps_and_invalid_or_duplicate_index_entries_fail_closed() {
654        let (mut manifest, bodies, _, _) = split_manifest();
655        manifest.extents[1].output_offset += 1;
656        assert!(assemble_provider_pack(&manifest, &bodies).is_err());
657
658        let (mut manifest, bodies, _, _) = split_manifest();
659        manifest.extents[1].objects[0].output_offset = manifest.extents[0].output_offset;
660        assert!(assemble_provider_pack(&manifest, &bodies).is_err());
661
662        let (mut manifest, bodies, _, _) = split_manifest();
663        manifest.extents[1].objects[0].id = manifest.extents[0].objects[0].id;
664        assert!(assemble_provider_pack(&manifest, &bodies).is_err());
665    }
666
667    #[test]
668    fn positional_spool_accepts_out_of_order_extents_and_is_byte_identical() {
669        let (manifest, bodies, source_pack, source_index) = split_manifest();
670        let root = tempfile::tempdir().unwrap();
671        let spool = ProviderPackSpool::new_in(root.path(), manifest).unwrap();
672        assert_eq!(
673            spool.file.metadata().unwrap().len(),
674            source_pack.len() as u64,
675            "the sparse spool must be pre-sized to the exact virtual pack length"
676        );
677        let writer = spool.writer();
678
679        writer.write_extent_chunk(1, 0, &bodies[1]).unwrap();
680        writer.mark_verified(1).unwrap();
681        writer.write_extent_chunk(0, 0, &bodies[0]).unwrap();
682        writer.mark_verified(0).unwrap();
683        drop(writer);
684
685        let completed = spool.finish().unwrap();
686        assert_eq!(fs::read(&completed.pack_path).unwrap(), source_pack);
687        assert_eq!(fs::read(&completed.index_path).unwrap(), source_index);
688    }
689
690    #[test]
691    fn positional_spool_rejects_range_overrun_duplicate_and_incomplete_coverage() {
692        let (manifest, bodies, _, _) = split_manifest();
693        let root = tempfile::tempdir().unwrap();
694        let spool = ProviderPackSpool::new_in(root.path(), manifest).unwrap();
695        let spool_dir = spool.dir.clone().unwrap();
696        let writer = spool.writer();
697
698        assert!(
699            writer
700                .write_extent_chunk(0, bodies[0].len() as u64, &[1])
701                .is_err()
702        );
703        writer.write_extent_chunk(0, 0, &bodies[0]).unwrap();
704        writer.mark_verified(0).unwrap();
705        assert!(writer.mark_verified(0).is_err());
706        drop(writer);
707
708        assert!(spool.finish().is_err());
709        assert!(
710            !spool_dir.exists(),
711            "failed finalization must remove partial spool state"
712        );
713    }
714
715    #[test]
716    fn dropping_partial_spool_removes_all_transfer_state() {
717        let (manifest, bodies, _, _) = split_manifest();
718        let root = tempfile::tempdir().unwrap();
719        let spool = ProviderPackSpool::new_in(root.path(), manifest).unwrap();
720        let spool_dir = spool.dir.clone().unwrap();
721        let writer = spool.writer();
722        writer
723            .write_extent_chunk(0, 0, &bodies[0][..bodies[0].len() / 2])
724            .unwrap();
725
726        drop(writer);
727        drop(spool);
728
729        assert!(!spool_dir.exists());
730    }
731}