Skip to main content

sley_odb/
install.rs

1use sley_core::{
2    CancelFlag, CancellableRead, GitError, MissingObjectContext, ObjectFormat, ObjectId, Result,
3};
4use sley_formats::{Bundle, BundleReference};
5use sley_object::{EncodedObject, ObjectType};
6use sley_pack::{
7    PackFile, PackIndex, PackIndexBuild, PackIndexProgress, PackInput, PackWrite, PackWriteOptions,
8    fix_thin_pack,
9};
10use std::collections::HashSet;
11use std::fs;
12use std::io::{Read, Write};
13use std::path::{Path, PathBuf};
14use std::sync::mpsc;
15
16use crate::{ObjectReader, ObjectWriter, unique_temp_path};
17
18use crate::pack::{FileObjectDatabase, ObjectDatabase};
19use crate::repack::pack_index_entries_match_writer;
20
21pub struct BundleUnbundleResult {
22    pub written_objects: Vec<ObjectId>,
23    pub references: Vec<BundleReference>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct PackUnpackResult {
28    pub written_objects: Vec<ObjectId>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct PackInstallResult {
33    pub pack_name: String,
34    pub pack_path: PathBuf,
35    pub index_path: PathBuf,
36    pub promisor_path: Option<PathBuf>,
37    pub object_ids: Vec<ObjectId>,
38}
39
40/// Disposable object database for an untrusted incoming pack.
41///
42/// Objects are written below the destination object directory, while an
43/// `info/alternates` entry makes the destination's existing objects available
44/// as delta bases and during connectivity validation. Dropping an unpromoted
45/// quarantine removes every incoming object. [`Self::promote`] moves accepted
46/// object files into the destination with per-file atomic renames.
47#[derive(Debug)]
48pub struct IncomingPackQuarantine {
49    git_dir: PathBuf,
50    object_dir: PathBuf,
51    destination_objects_dir: PathBuf,
52    format: ObjectFormat,
53    promisor_remote_present: bool,
54    promoted: bool,
55}
56
57impl IncomingPackQuarantine {
58    pub fn new(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<Self> {
59        let source_git_dir = git_dir.as_ref().to_path_buf();
60        let destination_objects_dir = crate::repository_objects_dir(&source_git_dir);
61        fs::create_dir_all(&destination_objects_dir)?;
62        let quarantine_git_dir = create_incoming_object_dir(&destination_objects_dir)?;
63        let object_dir = quarantine_git_dir.join("objects");
64        let result = (|| -> Result<()> {
65            fs::create_dir_all(object_dir.join("pack"))?;
66            fs::create_dir_all(object_dir.join("info"))?;
67            let mut alternates = vec![
68                fs::canonicalize(&destination_objects_dir)
69                    .unwrap_or_else(|_| destination_objects_dir.clone()),
70            ];
71            // FileObjectDatabase deliberately treats alternate entries as a
72            // flat search list. Preserve the destination's existing alternate
73            // visibility explicitly so a quarantined fetch into a shared or
74            // reference clone can validate objects that were already borrowed
75            // from its source repository.
76            alternates.extend(crate::registry::alternate_object_dirs(
77                &destination_objects_dir,
78            ));
79            let mut alternate_file = String::new();
80            for alternate in alternates {
81                let alternate = fs::canonicalize(&alternate).unwrap_or(alternate);
82                alternate_file.push_str(&alternate.to_string_lossy());
83                alternate_file.push('\n');
84            }
85            fs::write(object_dir.join("info/alternates"), alternate_file)?;
86            let shallow = quarantine_git_dir.join("shallow");
87            let source_shallow = source_git_dir.join("shallow");
88            if source_shallow.exists() {
89                fs::copy(source_shallow, shallow)?;
90            }
91            Ok(())
92        })();
93        if let Err(err) = result {
94            let _ = fs::remove_dir_all(&quarantine_git_dir);
95            return Err(err);
96        }
97        Ok(Self {
98            git_dir: quarantine_git_dir,
99            object_dir,
100            destination_objects_dir,
101            format,
102            promisor_remote_present: false,
103            promoted: false,
104        })
105    }
106
107    pub fn object_dir(&self) -> &Path {
108        &self.object_dir
109    }
110
111    /// A minimal bare repository path whose object database is quarantined.
112    /// Existing destination objects remain readable through its alternate.
113    pub fn git_dir(&self) -> &Path {
114        &self.git_dir
115    }
116
117    /// Mark promised objects as valid missing links while validating a fetch
118    /// into a partial-clone repository.
119    pub fn with_promisor_remote_present(mut self, present: bool) -> Self {
120        self.promisor_remote_present = present;
121        self
122    }
123
124    pub fn database(&self) -> FileObjectDatabase {
125        FileObjectDatabase::new(self.object_dir.clone(), self.format)
126            .with_promisor_remote_present(self.promisor_remote_present)
127    }
128
129    /// Promote all accepted loose and packed objects into the destination.
130    ///
131    /// Pack indexes and sidecars are made visible before their `.pack`; a
132    /// reader therefore never observes a pack without its index. If any rename
133    /// fails, files newly moved by this call are rolled back into quarantine.
134    pub fn promote(mut self) -> Result<()> {
135        let mut files = incoming_object_files(&self.object_dir)?;
136        files.sort_by_key(|path| {
137            let is_pack = path.extension().is_some_and(|ext| ext == "pack");
138            (is_pack, path.clone())
139        });
140        let mut moved = Vec::new();
141        for source in files {
142            let relative = source
143                .strip_prefix(&self.object_dir)
144                .map_err(|_| GitError::InvalidPath("incoming object escaped quarantine".into()))?;
145            let destination = self.destination_objects_dir.join(relative);
146            let parent = destination.parent().ok_or_else(|| {
147                GitError::InvalidPath("incoming object has no destination parent".into())
148            })?;
149            fs::create_dir_all(parent)?;
150            if destination.exists() {
151                fs::remove_file(&source)?;
152                continue;
153            }
154            if let Err(err) = fs::rename(&source, &destination) {
155                for (promoted, staged) in moved.iter().rev() {
156                    let _ = fs::rename(promoted, staged);
157                }
158                return Err(GitError::Io(err.to_string()));
159            }
160            moved.push((destination, source));
161        }
162        self.promoted = true;
163        fs::remove_dir_all(&self.git_dir)?;
164        Ok(())
165    }
166}
167
168impl Drop for IncomingPackQuarantine {
169    fn drop(&mut self) {
170        if !self.promoted {
171            let _ = fs::remove_dir_all(&self.git_dir);
172        }
173    }
174}
175
176fn create_incoming_object_dir(objects_dir: &Path) -> Result<PathBuf> {
177    for _ in 0..100 {
178        let object_dir = unique_temp_path(objects_dir).with_extension("incoming");
179        match fs::create_dir(&object_dir) {
180            Ok(()) => return Ok(object_dir),
181            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
182            Err(err) => return Err(GitError::Io(err.to_string())),
183        }
184    }
185    Err(GitError::Io(
186        "could not create incoming object quarantine".into(),
187    ))
188}
189
190fn incoming_object_files(object_dir: &Path) -> Result<Vec<PathBuf>> {
191    let mut files = Vec::new();
192    let pack_dir = object_dir.join("pack");
193    if pack_dir.exists() {
194        for entry in fs::read_dir(pack_dir)? {
195            let path = entry?.path();
196            if path.is_file() {
197                files.push(path);
198            }
199        }
200    }
201    for entry in fs::read_dir(object_dir)? {
202        let entry = entry?;
203        let name = entry.file_name();
204        let name = name.to_string_lossy();
205        if name.len() != 2 || !name.bytes().all(|byte| byte.is_ascii_hexdigit()) {
206            continue;
207        }
208        for loose in fs::read_dir(entry.path())? {
209            let path = loose?.path();
210            if path.is_file() {
211                files.push(path);
212            }
213        }
214    }
215    Ok(files)
216}
217
218#[derive(Debug)]
219pub struct RawPackStreamingInstall {
220    format: ObjectFormat,
221    expected_pack_id: ObjectId,
222    expected_pack_size: u64,
223    options: RawPackInstallOptions,
224    pack_dir: PathBuf,
225    pack_name: String,
226    pack_path: PathBuf,
227    index_path: PathBuf,
228    temp_pack_path: PathBuf,
229    file: Option<fs::File>,
230    written: u64,
231    finished: bool,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct RawPackInstallResult {
236    pub object_ids: Vec<ObjectId>,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct RawPackIndexResult {
241    pub pack_id: ObjectId,
242    pub index: Vec<u8>,
243    pub objects: Vec<RawPackIndexedObject>,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct RawPackIndexedObject {
248    pub oid: ObjectId,
249    pub object_type: ObjectType,
250    pub size: u64,
251    pub offset: u64,
252}
253
254/// Monotonic receipt and indexing counters for one staged pack installation.
255#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
256pub struct PackInstallProgress {
257    /// Pack bytes received from the input so far.
258    pub received_bytes: u64,
259    /// Objects fully inflated, resolved, and hashed so far.
260    pub indexed_objects: u64,
261    /// Total objects declared by the pack header once it is available.
262    pub total_objects: u64,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct ReachablePackFile {
267    pub pack_path: PathBuf,
268    pub pack_size: u64,
269    pub checksum: ObjectId,
270    pub object_count: usize,
271    pub delta_count: u32,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct ReachablePackWriteSummary {
276    pub index: Vec<u8>,
277    pub checksum: ObjectId,
278    pub object_count: usize,
279    pub delta_count: u32,
280    pub pack_size: u64,
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
284pub struct RawPackInstallOptions {
285    pub promisor: bool,
286    /// Maximum raw pack bytes to accept from the reader. `None` means unlimited,
287    /// mirroring unset `fetch.maxInputSize` / `transfer.maxSize`.
288    pub max_input_size: Option<u64>,
289}
290
291pub trait RawPackInstaller {
292    fn install_raw_pack_from_reader_with_options<R>(
293        &self,
294        reader: &mut R,
295        options: RawPackInstallOptions,
296    ) -> Result<RawPackInstallResult>
297    where
298        R: Read;
299
300    fn install_raw_pack_from_reader<R>(&self, reader: &mut R) -> Result<RawPackInstallResult>
301    where
302        R: Read,
303    {
304        self.install_raw_pack_from_reader_with_options(reader, RawPackInstallOptions::default())
305    }
306
307    /// Install a raw pack while reporting receipt and indexing progress.
308    ///
309    /// Delegates to [`install_raw_pack_from_reader_with_progress_and_cancel`] with
310    /// a never-cancel flag. The default implementation still polls cancel
311    /// between reads via [`CancellableRead`].
312    ///
313    /// [`install_raw_pack_from_reader_with_progress_and_cancel`]: RawPackInstaller::install_raw_pack_from_reader_with_progress_and_cancel
314    fn install_raw_pack_from_reader_with_progress<R, F>(
315        &self,
316        reader: &mut R,
317        options: RawPackInstallOptions,
318        progress: F,
319    ) -> Result<RawPackInstallResult>
320    where
321        R: Read,
322        F: FnMut(PackInstallProgress),
323    {
324        self.install_raw_pack_from_reader_with_progress_and_cancel(
325            reader,
326            options,
327            CancelFlag::never(),
328            progress,
329        )
330    }
331
332    /// Install a raw pack with cooperative cancellation and optional progress.
333    ///
334    /// The default implementation ignores `progress`, polls `cancel` before the
335    /// install, and wraps `reader` in [`CancellableRead`] so a trip mid-stream
336    /// surfaces as [`GitError::Cancelled`]. [`FileObjectDatabase`] overrides this
337    /// to poll during both receipt and parallel indexing.
338    fn install_raw_pack_from_reader_with_progress_and_cancel<R, F>(
339        &self,
340        reader: &mut R,
341        options: RawPackInstallOptions,
342        cancel: CancelFlag<'_>,
343        _progress: F,
344    ) -> Result<RawPackInstallResult>
345    where
346        R: Read,
347        F: FnMut(PackInstallProgress),
348    {
349        cancel.check()?;
350        let mut cancellable = CancellableRead::new(reader, cancel.as_ref());
351        self.install_raw_pack_from_reader_with_options(&mut cancellable, options)
352            .map_err(map_install_cancel_error)
353    }
354}
355
356/// Map cancel-flavored install failures (from [`CancellableRead`] I/O or pack
357/// indexing checks) onto [`GitError::Cancelled`].
358///
359/// Cancellation is detected structurally via [`GitError::is_cancelled`]
360/// (explicit variant, cancel-payload intercept, or Interrupted kind) — no
361/// message-text sniffing.
362fn map_install_cancel_error(err: GitError) -> GitError {
363    if err.is_cancelled() {
364        GitError::Cancelled
365    } else {
366        err
367    }
368}
369
370const PACK_RECEIVE_BUFFER_BYTES: usize = 1024 * 1024;
371const PACK_RECEIVE_QUEUE_DEPTH: usize = 4;
372const PACK_RECEIVE_PROGRESS_BYTES: u64 = 256 * 1024;
373
374#[derive(Debug, Clone, Copy)]
375struct PackReceiveSummary {
376    bytes: u64,
377}
378
379fn receive_pack_to_file<R, F>(
380    reader: &mut R,
381    mut file: fs::File,
382    max_input_size: Option<u64>,
383    cancel: CancelFlag<'_>,
384    progress: &mut F,
385) -> Result<PackReceiveSummary>
386where
387    R: Read,
388    F: FnMut(PackInstallProgress),
389{
390    let (filled_sender, filled_receiver) = mpsc::sync_channel::<Vec<u8>>(PACK_RECEIVE_QUEUE_DEPTH);
391    let (empty_sender, empty_receiver) = mpsc::sync_channel::<Vec<u8>>(PACK_RECEIVE_QUEUE_DEPTH);
392    for _ in 0..PACK_RECEIVE_QUEUE_DEPTH {
393        empty_sender
394            .send(vec![0u8; PACK_RECEIVE_BUFFER_BYTES])
395            .map_err(|_| GitError::Io("could not initialize pack receive buffers".into()))?;
396    }
397
398    std::thread::scope(|scope| {
399        let writer = scope.spawn(move || -> Result<()> {
400            for mut chunk in filled_receiver {
401                #[cfg(feature = "fetch-profile")]
402                let _profile_span = sley_core::fetch_profile::Span::enter(
403                    sley_core::fetch_profile::Stage::ObjectStoreWrite,
404                );
405                file.write_all(&chunk)?;
406                #[cfg(feature = "fetch-profile")]
407                {
408                    sley_core::fetch_profile::add_count(
409                        sley_core::fetch_profile::Stage::ObjectStoreWrite,
410                        1,
411                    );
412                    sley_core::fetch_profile::add_bytes(
413                        sley_core::fetch_profile::Stage::ObjectStoreWrite,
414                        chunk.len() as u64,
415                    );
416                }
417                chunk.resize(PACK_RECEIVE_BUFFER_BYTES, 0);
418                if empty_sender.send(chunk).is_err() {
419                    break;
420                }
421            }
422            file.flush()?;
423            file.sync_all()?;
424            #[cfg(feature = "fetch-profile")]
425            sley_core::fetch_profile::add_fsync();
426            Ok(())
427        });
428
429        let receive_result = (|| -> Result<PackReceiveSummary> {
430            let mut bytes = 0u64;
431            let mut last_progress = 0u64;
432            let mut header = Vec::with_capacity(12);
433            let mut total_objects = 0u64;
434            loop {
435                cancel.check()?;
436                let mut chunk = empty_receiver.recv().map_err(|_| {
437                    GitError::Io("pack staging writer stopped before receive completed".into())
438                })?;
439                let read = reader.read(&mut chunk)?;
440                if read == 0 {
441                    break;
442                }
443                chunk.truncate(read);
444                bytes = bytes
445                    .checked_add(read as u64)
446                    .ok_or_else(|| GitError::InvalidFormat("pack size overflow".into()))?;
447                if let Some(limit) = max_input_size
448                    && bytes > limit
449                {
450                    return Err(GitError::InvalidFormat(format!(
451                        "pack exceeds maximum allowed size ({limit})"
452                    )));
453                }
454                if header.len() < 12 {
455                    let needed = 12 - header.len();
456                    header.extend_from_slice(&chunk[..needed.min(chunk.len())]);
457                    if header.len() == 12 && &header[..4] == b"PACK" {
458                        total_objects = u64::from(u32::from_be_bytes([
459                            header[8], header[9], header[10], header[11],
460                        ]));
461                        progress(PackInstallProgress {
462                            received_bytes: 12,
463                            indexed_objects: 0,
464                            total_objects,
465                        });
466                        last_progress = 12;
467                        cancel.check()?;
468                    }
469                }
470                filled_sender.send(chunk).map_err(|_| {
471                    GitError::Io("pack staging writer stopped before receive completed".into())
472                })?;
473                if bytes.saturating_sub(last_progress) >= PACK_RECEIVE_PROGRESS_BYTES {
474                    last_progress = bytes;
475                    progress(PackInstallProgress {
476                        received_bytes: bytes,
477                        indexed_objects: 0,
478                        total_objects,
479                    });
480                    cancel.check()?;
481                }
482            }
483            progress(PackInstallProgress {
484                received_bytes: bytes,
485                indexed_objects: 0,
486                total_objects,
487            });
488            cancel.check()?;
489            Ok(PackReceiveSummary { bytes })
490        })();
491        drop(filled_sender);
492        let write_result = match writer.join() {
493            Ok(result) => result,
494            Err(_) => Err(GitError::Io("pack staging writer panicked".into())),
495        };
496        let summary = receive_result?;
497        write_result?;
498        Ok(summary)
499    })
500}
501
502#[cfg(test)]
503pub(crate) const REACHABLE_PACK_STREAMING_MIN_OBJECTS: usize = 32;
504#[cfg(not(test))]
505pub(crate) const REACHABLE_PACK_STREAMING_MIN_OBJECTS: usize = 4096;
506
507#[derive(Debug, Clone, PartialEq, Eq)]
508pub enum ObjectPrefixResolution {
509    Missing,
510    Unique(ObjectId),
511    Ambiguous(Vec<ObjectId>),
512}
513
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct ObjectStorageInfo {
516    pub disk_size: u64,
517    pub deltabase: ObjectId,
518}
519
520impl RawPackInstaller for FileObjectDatabase {
521    fn install_raw_pack_from_reader_with_options<R>(
522        &self,
523        reader: &mut R,
524        options: RawPackInstallOptions,
525    ) -> Result<RawPackInstallResult>
526    where
527        R: Read,
528    {
529        let result =
530            FileObjectDatabase::install_raw_pack_from_reader_with_options(self, reader, options)?;
531        Ok(RawPackInstallResult {
532            object_ids: result.object_ids,
533        })
534    }
535
536    fn install_raw_pack_from_reader_with_progress_and_cancel<R, F>(
537        &self,
538        reader: &mut R,
539        options: RawPackInstallOptions,
540        cancel: CancelFlag<'_>,
541        progress: F,
542    ) -> Result<RawPackInstallResult>
543    where
544        R: Read,
545        F: FnMut(PackInstallProgress),
546    {
547        let result = FileObjectDatabase::install_raw_pack_from_reader_with_progress_and_cancel(
548            self, reader, options, cancel, progress,
549        )?;
550        Ok(RawPackInstallResult {
551            object_ids: result.object_ids,
552        })
553    }
554}
555
556impl RawPackInstaller for ObjectDatabase {
557    fn install_raw_pack_from_reader_with_options<R>(
558        &self,
559        reader: &mut R,
560        options: RawPackInstallOptions,
561    ) -> Result<RawPackInstallResult>
562    where
563        R: Read,
564    {
565        let mut pack_bytes = Vec::new();
566        match options.max_input_size {
567            Some(limit) => {
568                reader
569                    .take(limit.saturating_add(1))
570                    .read_to_end(&mut pack_bytes)?;
571                if pack_bytes.len() as u64 > limit {
572                    return Err(GitError::InvalidFormat(format!(
573                        "pack exceeds maximum allowed size ({limit})"
574                    )));
575                }
576            }
577            None => {
578                reader.read_to_end(&mut pack_bytes)?;
579            }
580        }
581        let result = unpack_packfile_objects(&pack_bytes, self.format, self)?;
582        Ok(RawPackInstallResult {
583            object_ids: result.written_objects,
584        })
585    }
586}
587
588impl RawPackStreamingInstall {
589    pub fn bytes_written(&self) -> u64 {
590        self.written
591    }
592
593    pub fn pack_path(&self) -> &Path {
594        &self.pack_path
595    }
596
597    pub fn index_path(&self) -> &Path {
598        &self.index_path
599    }
600
601    pub fn finish(mut self) -> Result<PackInstallResult> {
602        let result = (|| -> Result<PackInstallResult> {
603            let mut file = self.file.take().ok_or_else(|| {
604                GitError::InvalidFormat("raw pack stream already finished".into())
605            })?;
606            #[cfg(feature = "fetch-profile")]
607            let _profile_span = sley_core::fetch_profile::Span::enter(
608                sley_core::fetch_profile::Stage::ObjectStoreWrite,
609            );
610            file.flush()?;
611            file.sync_all()?;
612            #[cfg(feature = "fetch-profile")]
613            sley_core::fetch_profile::add_fsync();
614            drop(file);
615
616            if self.written != self.expected_pack_size {
617                return Err(GitError::InvalidFormat(format!(
618                    "raw pack stream length mismatch: expected {}, got {}",
619                    self.expected_pack_size, self.written
620                )));
621            }
622
623            let built = {
624                let mapped = sley_mmap::MappedFile::open_pack(&self.temp_pack_path)?;
625                PackIndex::write_v2_for_pack(mapped.as_bytes(), self.format)?
626            };
627            if built.pack_checksum != self.expected_pack_id {
628                return Err(GitError::InvalidFormat(format!(
629                    "raw pack stream checksum mismatch: expected {}, got {}",
630                    self.expected_pack_id, built.pack_checksum
631                )));
632            }
633
634            match fs::rename(&self.temp_pack_path, &self.pack_path) {
635                Ok(()) => {}
636                Err(_) if self.pack_path.exists() => {
637                    let _ = fs::remove_file(&self.temp_pack_path);
638                }
639                Err(err) => return Err(GitError::Io(err.to_string())),
640            }
641            write_pack_component(&self.index_path, &built.index)?;
642            let promisor_path = write_promisor_pack_sidecar(
643                &self.pack_dir,
644                &self.pack_name,
645                self.options.promisor,
646            )?;
647            Ok(PackInstallResult {
648                pack_name: self.pack_name.clone(),
649                pack_path: self.pack_path.clone(),
650                index_path: self.index_path.clone(),
651                promisor_path,
652                object_ids: built.entries.iter().map(|entry| entry.oid).collect(),
653            })
654        })();
655
656        if result.is_ok() {
657            self.finished = true;
658        } else {
659            let _ = fs::remove_file(&self.temp_pack_path);
660        }
661        result
662    }
663}
664
665impl Write for RawPackStreamingInstall {
666    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
667        let next_written = self.written.checked_add(buf.len() as u64).ok_or_else(|| {
668            std::io::Error::new(std::io::ErrorKind::InvalidData, "pack size overflow")
669        })?;
670        if next_written > self.expected_pack_size {
671            return Err(std::io::Error::new(
672                std::io::ErrorKind::InvalidData,
673                format!(
674                    "raw pack stream exceeds expected size {}; got at least {}",
675                    self.expected_pack_size, next_written
676                ),
677            ));
678        }
679        let file = self.file.as_mut().ok_or_else(|| {
680            std::io::Error::new(
681                std::io::ErrorKind::BrokenPipe,
682                "raw pack stream already finished",
683            )
684        })?;
685        let written = file.write(buf)?;
686        self.written = self.written.checked_add(written as u64).ok_or_else(|| {
687            std::io::Error::new(std::io::ErrorKind::InvalidData, "pack size overflow")
688        })?;
689        Ok(written)
690    }
691
692    fn flush(&mut self) -> std::io::Result<()> {
693        match self.file.as_mut() {
694            Some(file) => file.flush(),
695            None => Ok(()),
696        }
697    }
698}
699
700impl Drop for RawPackStreamingInstall {
701    fn drop(&mut self) {
702        if !self.finished {
703            let _ = self.file.take();
704            let _ = fs::remove_file(&self.temp_pack_path);
705        }
706    }
707}
708
709pub fn verify_bundle_prerequisites<R: ObjectReader>(bundle: &Bundle, reader: &R) -> Result<()> {
710    let mut missing = Vec::new();
711    for prerequisite in &bundle.prerequisites {
712        match reader.read_object(&prerequisite.oid) {
713            Ok(object) => {
714                let actual = object.object_id(bundle.format)?;
715                if actual != prerequisite.oid {
716                    return Err(GitError::InvalidObject(format!(
717                        "bundle prerequisite {} hashes to {actual}",
718                        prerequisite.oid
719                    )));
720                }
721            }
722            Err(GitError::NotFound(_)) => missing.push(prerequisite.oid),
723            Err(err) => return Err(err),
724        }
725    }
726    if missing.is_empty() {
727        return Ok(());
728    }
729    Err(GitError::object_not_found_in(
730        missing[0],
731        MissingObjectContext::PackInstall,
732    ))
733}
734
735pub fn unbundle_objects<R, W>(
736    bundle: &Bundle,
737    prerequisite_reader: &R,
738    writer: &mut W,
739) -> Result<BundleUnbundleResult>
740where
741    R: ObjectReader,
742    W: ObjectWriter,
743{
744    verify_bundle_prerequisites(bundle, prerequisite_reader)?;
745    let pack = PackFile::parse_bundle(bundle)?;
746    let written_objects = write_pack_objects(pack, writer, "bundle")?.written_objects;
747    Ok(BundleUnbundleResult {
748        written_objects,
749        references: bundle.references.clone(),
750    })
751}
752
753pub fn install_bundle_pack<R>(
754    bundle: &Bundle,
755    prerequisite_reader: &R,
756    destination: &impl RawPackInstaller,
757) -> Result<BundleUnbundleResult>
758where
759    R: ObjectReader,
760{
761    verify_bundle_prerequisites(bundle, prerequisite_reader)?;
762    let mut reader = bundle.pack.as_slice();
763    let install = destination.install_raw_pack_from_reader(&mut reader)?;
764    Ok(BundleUnbundleResult {
765        written_objects: install.object_ids,
766        references: bundle.references.clone(),
767    })
768}
769
770pub fn unpack_packfile_objects<W>(
771    pack_bytes: &[u8],
772    format: ObjectFormat,
773    writer: &W,
774) -> Result<PackUnpackResult>
775where
776    W: ObjectWriter,
777{
778    let pack = PackFile::parse(pack_bytes, format)?;
779    write_pack_objects(pack, writer, "pack")
780}
781
782pub fn index_raw_pack(pack_bytes: &[u8], format: ObjectFormat) -> Result<RawPackIndexResult> {
783    let built = PackIndex::write_v2_for_pack(pack_bytes, format)?;
784    Ok(index_build_to_raw_result(built))
785}
786
787pub fn index_raw_pack_file(
788    path: impl AsRef<Path>,
789    format: ObjectFormat,
790) -> Result<RawPackIndexResult> {
791    let mapped = sley_mmap::MappedFile::open_pack(path.as_ref())?;
792    Ok(index_build_to_raw_result(PackIndex::write_v2_for_pack(
793        mapped.as_bytes(),
794        format,
795    )?))
796}
797
798fn index_build_to_raw_result(built: PackIndexBuild) -> RawPackIndexResult {
799    let objects = built
800        .objects
801        .into_iter()
802        .map(|object| RawPackIndexedObject {
803            oid: object.oid,
804            object_type: object.object_type,
805            size: object.size,
806            offset: object.offset,
807        })
808        .collect::<Vec<_>>();
809    RawPackIndexResult {
810        pack_id: built.pack_checksum,
811        index: built.index,
812        objects,
813    }
814}
815
816fn write_pack_objects<W>(pack: PackFile, writer: &W, source: &str) -> Result<PackUnpackResult>
817where
818    W: ObjectWriter,
819{
820    let mut written_objects = Vec::with_capacity(pack.entries.len());
821    for entry in pack.entries {
822        let expected = entry.entry.oid;
823        let actual = writer.write_object(entry.object)?;
824        if actual != expected {
825            return Err(GitError::InvalidObject(format!(
826                "{source} object id mismatch: expected {expected}, wrote {actual}"
827            )));
828        }
829        written_objects.push(actual);
830    }
831    Ok(PackUnpackResult { written_objects })
832}
833pub(crate) fn validate_pack_checksum(
834    pack: &[u8],
835    format: ObjectFormat,
836    expected: &ObjectId,
837    context: &str,
838) -> Result<()> {
839    if expected.format() != format {
840        return Err(GitError::InvalidObjectId(format!(
841            "{context} checksum format does not match object format"
842        )));
843    }
844    let hash_len = format.raw_len();
845    if pack.len() < 12 + hash_len {
846        return Err(GitError::InvalidFormat(format!(
847            "{context} pack file too short"
848        )));
849    }
850    if &pack[..4] != b"PACK" {
851        return Err(GitError::InvalidFormat(format!(
852            "{context} pack file missing PACK signature"
853        )));
854    }
855    let trailer_offset = pack.len() - hash_len;
856    let actual = sley_core::digest_bytes(format, &pack[..trailer_offset])?;
857    let trailer = ObjectId::from_raw(format, &pack[trailer_offset..])?;
858    if &actual != expected || trailer != *expected {
859        return Err(GitError::InvalidFormat(format!(
860            "{context} pack checksum does not match generated pack"
861        )));
862    }
863    Ok(())
864}
865
866impl FileObjectDatabase {
867    pub fn install_pack(&self, pack: &PackWrite) -> Result<PackInstallResult> {
868        self.install_pack_with_options(pack, RawPackInstallOptions::default())
869    }
870
871    pub fn write_blob_as_pack(
872        &self,
873        oid: ObjectId,
874        object: &EncodedObject,
875        compression_level: u32,
876    ) -> Result<ObjectId> {
877        if object.object_type != ObjectType::Blob {
878            return Err(GitError::InvalidObject(
879                "write_blob_as_pack requires a blob object".into(),
880            ));
881        }
882        if oid.format() != self.format {
883            return Err(GitError::InvalidObjectId(format!(
884                "object {oid} uses {}, store uses {}",
885                oid.format().name(),
886                self.format.name()
887            )));
888        }
889        if self.contains(&oid)? {
890            return Ok(oid);
891        }
892        let input = [PackInput { oid: &oid, object }];
893        let options = PackWriteOptions::new()
894            .with_window(0)
895            .with_depth(0)
896            .with_reorder(false)
897            .with_compression_level(compression_level);
898        let pack =
899            PackFile::write_packed_with_known_ids_and_options(&input, self.format, &options)?;
900        self.install_pack(&pack)?;
901        Ok(oid)
902    }
903
904    pub fn write_blobs_as_pack(
905        &self,
906        objects: &[(ObjectId, EncodedObject)],
907        compression_level: u32,
908    ) -> Result<()> {
909        let mut seen = HashSet::with_capacity(objects.len());
910        let mut inputs = Vec::new();
911        for (oid, object) in objects {
912            if object.object_type != ObjectType::Blob {
913                return Err(GitError::InvalidObject(
914                    "write_blobs_as_pack requires blob objects".into(),
915                ));
916            }
917            if oid.format() != self.format {
918                return Err(GitError::InvalidObjectId(format!(
919                    "object {oid} uses {}, store uses {}",
920                    oid.format().name(),
921                    self.format.name()
922                )));
923            }
924            if seen.insert(*oid) && !self.contains(oid)? {
925                inputs.push(PackInput { oid, object });
926            }
927        }
928        if inputs.is_empty() {
929            return Ok(());
930        }
931        let options = PackWriteOptions::new()
932            .with_window(0)
933            .with_depth(0)
934            .with_reorder(false)
935            .with_compression_level(compression_level);
936        let pack =
937            PackFile::write_packed_with_known_ids_and_options(&inputs, self.format, &options)?;
938        self.install_pack(&pack)?;
939        Ok(())
940    }
941
942    pub fn install_pack_with_options(
943        &self,
944        pack: &PackWrite,
945        options: RawPackInstallOptions,
946    ) -> Result<PackInstallResult> {
947        if pack.checksum.format() != self.format {
948            return Err(GitError::InvalidObjectId(format!(
949                "pack checksum uses {}, store uses {}",
950                pack.checksum.format().name(),
951                self.format.name()
952            )));
953        }
954        for entry in &pack.entries {
955            if entry.oid.format() != self.format {
956                return Err(GitError::InvalidObjectId(format!(
957                    "pack entry {} uses {}, store uses {}",
958                    entry.oid,
959                    entry.oid.format().name(),
960                    self.format.name()
961                )));
962            }
963        }
964        let canonical_index = PackIndex::write_v2_for_pack(&pack.pack, self.format)?;
965        let parsed_index = PackIndex::parse(&pack.index, self.format)?;
966        if canonical_index.pack_checksum != pack.checksum
967            || parsed_index.pack_checksum != pack.checksum
968        {
969            return Err(GitError::InvalidFormat(
970                "pack and index checksums do not match pack write".into(),
971            ));
972        }
973        if pack.index != canonical_index.index {
974            return Err(GitError::InvalidFormat(
975                "pack index does not match pack contents".into(),
976            ));
977        }
978
979        let pack_dir = self.objects_dir.join("pack");
980        fs::create_dir_all(&pack_dir)?;
981        let pack_name = format!("pack-{}", pack.checksum.to_hex());
982        let pack_path = pack_dir.join(format!("{pack_name}.pack"));
983        let index_path = pack_dir.join(format!("{pack_name}.idx"));
984        if !pack_path.exists() || !index_path.exists() {
985            write_pack_component(&pack_path, &pack.pack)?;
986            write_pack_component(&index_path, &pack.index)?;
987        }
988        let promisor_path = write_promisor_pack_sidecar(&pack_dir, &pack_name, options.promisor)?;
989        Ok(PackInstallResult {
990            pack_name,
991            pack_path,
992            index_path,
993            promisor_path,
994            object_ids: canonical_index
995                .entries
996                .iter()
997                .map(|entry| entry.oid)
998                .collect(),
999        })
1000    }
1001
1002    /// Install a pack that was produced in this process by [`PackFile::write_packed`].
1003    ///
1004    /// Unlike [`Self::install_raw_pack_from_reader_with_options`], this does not re-inflate
1005    /// every pack entry to rebuild the index. It validates the generated pack
1006    /// trailer and generated index against the writer's object ids, CRCs, and
1007    /// offsets, then writes those bytes directly. Use the raw installer for
1008    /// arbitrary pack bytes received from an untrusted transport.
1009    pub fn install_written_pack(&self, pack: &PackWrite) -> Result<PackInstallResult> {
1010        self.install_written_pack_with_options(pack, RawPackInstallOptions::default())
1011    }
1012
1013    pub fn install_written_pack_with_options(
1014        &self,
1015        pack: &PackWrite,
1016        options: RawPackInstallOptions,
1017    ) -> Result<PackInstallResult> {
1018        validate_pack_checksum(&pack.pack, self.format, &pack.checksum, "pack write")?;
1019        let parsed_index = PackIndex::parse(&pack.index, self.format)?;
1020        if parsed_index.pack_checksum != pack.checksum {
1021            return Err(GitError::InvalidFormat(
1022                "pack write index checksum does not match pack".into(),
1023            ));
1024        }
1025        if !pack_index_entries_match_writer(&parsed_index.entries, &pack.entries) {
1026            return Err(GitError::InvalidFormat(
1027                "pack write index does not match generated entries".into(),
1028            ));
1029        }
1030        self.install_generated_pack_unchecked(pack, options)
1031    }
1032
1033    fn install_generated_pack_unchecked(
1034        &self,
1035        pack: &PackWrite,
1036        options: RawPackInstallOptions,
1037    ) -> Result<PackInstallResult> {
1038        let pack_dir = self.objects_dir.join("pack");
1039        fs::create_dir_all(&pack_dir)?;
1040        let pack_name = format!("pack-{}", pack.checksum.to_hex());
1041        let pack_path = pack_dir.join(format!("{pack_name}.pack"));
1042        let index_path = pack_dir.join(format!("{pack_name}.idx"));
1043        if !pack_path.exists() || !index_path.exists() {
1044            write_pack_component(&pack_path, &pack.pack)?;
1045            write_pack_component(&index_path, &pack.index)?;
1046        }
1047        let promisor_path = write_promisor_pack_sidecar(&pack_dir, &pack_name, options.promisor)?;
1048        Ok(PackInstallResult {
1049            pack_name,
1050            pack_path,
1051            index_path,
1052            promisor_path,
1053            object_ids: pack.entries.iter().map(|entry| entry.oid).collect(),
1054        })
1055    }
1056
1057    pub(crate) fn install_pack_file_from_temp(
1058        &self,
1059        temp_pack_path: &Path,
1060        pack_checksum: ObjectId,
1061        index: &[u8],
1062        object_ids: Vec<ObjectId>,
1063        options: RawPackInstallOptions,
1064    ) -> Result<PackInstallResult> {
1065        let pack_dir = self.objects_dir.join("pack");
1066        fs::create_dir_all(&pack_dir)?;
1067        let pack_name = format!("pack-{}", pack_checksum.to_hex());
1068        let pack_path = pack_dir.join(format!("{pack_name}.pack"));
1069        let index_path = pack_dir.join(format!("{pack_name}.idx"));
1070        match fs::rename(temp_pack_path, &pack_path) {
1071            Ok(()) => {}
1072            Err(_) if pack_path.exists() => {
1073                let _ = fs::remove_file(temp_pack_path);
1074            }
1075            Err(err) => return Err(GitError::Io(err.to_string())),
1076        }
1077        write_pack_component(&index_path, index)?;
1078        let promisor_path = write_promisor_pack_sidecar(&pack_dir, &pack_name, options.promisor)?;
1079        Ok(PackInstallResult {
1080            pack_name,
1081            pack_path,
1082            index_path,
1083            promisor_path,
1084            object_ids,
1085        })
1086    }
1087
1088    pub fn install_raw_pack_from_reader<R>(&self, reader: &mut R) -> Result<PackInstallResult>
1089    where
1090        R: Read,
1091    {
1092        self.install_raw_pack_from_reader_with_options(reader, RawPackInstallOptions::default())
1093    }
1094
1095    /// Install a pack whose ref-deltas may use objects already available from
1096    /// this database (including alternates) as bases. Required bases are
1097    /// appended as full entries before the pack is stored, so the installed
1098    /// pack remains independently valid.
1099    pub fn install_raw_pack_from_reader_with_external_bases<R>(
1100        &self,
1101        reader: &mut R,
1102    ) -> Result<PackInstallResult>
1103    where
1104        R: Read,
1105    {
1106        let mut pack = Vec::new();
1107        reader.read_to_end(&mut pack)?;
1108        let fixed = fix_thin_pack(&pack, self.format, |oid| match self.read_object(oid) {
1109            Ok(object) => Ok(Some((*object).clone())),
1110            Err(GitError::NotFound(_)) => Ok(None),
1111            Err(err) => Err(err),
1112        })?;
1113        let pack = fixed.pack;
1114        let built = fixed.index;
1115        let pack_dir = self.objects_dir.join("pack");
1116        fs::create_dir_all(&pack_dir)?;
1117        let temp_pack_path = unique_temp_path(&pack_dir).with_extension("pack");
1118        fs::write(&temp_pack_path, &pack)?;
1119        let result = self.install_pack_file_from_temp(
1120            &temp_pack_path,
1121            built.pack_checksum,
1122            &built.index,
1123            built.entries.iter().map(|entry| entry.oid).collect(),
1124            RawPackInstallOptions::default(),
1125        );
1126        if result.is_err() {
1127            let _ = fs::remove_file(&temp_pack_path);
1128        }
1129        result
1130    }
1131
1132    pub fn begin_raw_pack_install(
1133        &self,
1134        expected_pack_id: ObjectId,
1135        expected_pack_size: u64,
1136    ) -> Result<RawPackStreamingInstall> {
1137        self.begin_raw_pack_install_with_options(
1138            expected_pack_id,
1139            expected_pack_size,
1140            RawPackInstallOptions::default(),
1141        )
1142    }
1143
1144    pub fn begin_raw_pack_install_with_options(
1145        &self,
1146        expected_pack_id: ObjectId,
1147        expected_pack_size: u64,
1148        options: RawPackInstallOptions,
1149    ) -> Result<RawPackStreamingInstall> {
1150        if expected_pack_id.format() != self.format {
1151            return Err(GitError::InvalidObjectId(format!(
1152                "pack checksum uses {}, store uses {}",
1153                expected_pack_id.format().name(),
1154                self.format.name()
1155            )));
1156        }
1157        let pack_dir = self.objects_dir.join("pack");
1158        fs::create_dir_all(&pack_dir)?;
1159        let pack_name = format!("pack-{}", expected_pack_id.to_hex());
1160        let pack_path = pack_dir.join(format!("{pack_name}.pack"));
1161        let index_path = pack_dir.join(format!("{pack_name}.idx"));
1162        let temp_pack_path = unique_temp_path(&pack_dir).with_extension("pack");
1163        let file = fs::OpenOptions::new()
1164            .write(true)
1165            .create_new(true)
1166            .open(&temp_pack_path)?;
1167        Ok(RawPackStreamingInstall {
1168            format: self.format,
1169            expected_pack_id,
1170            expected_pack_size,
1171            options,
1172            pack_dir,
1173            pack_name,
1174            pack_path,
1175            index_path,
1176            temp_pack_path,
1177            file: Some(file),
1178            written: 0,
1179            finished: false,
1180        })
1181    }
1182
1183    pub fn install_raw_pack_from_reader_with_options<R>(
1184        &self,
1185        reader: &mut R,
1186        options: RawPackInstallOptions,
1187    ) -> Result<PackInstallResult>
1188    where
1189        R: Read,
1190    {
1191        self.install_raw_pack_from_reader_with_progress(reader, options, |_| {})
1192    }
1193
1194    /// [`install_raw_pack_from_reader_with_options`] that reports receipt and
1195    /// indexing progress. The callback advances while the bounded spool drains
1196    /// `reader`, then while the immutable mapped pack is indexed.
1197    ///
1198    /// Delegates to [`Self::install_raw_pack_from_reader_with_progress_and_cancel`]
1199    /// with a never-cancel flag.
1200    ///
1201    /// [`install_raw_pack_from_reader_with_options`]: FileObjectDatabase::install_raw_pack_from_reader_with_options
1202    pub fn install_raw_pack_from_reader_with_progress<R, F>(
1203        &self,
1204        reader: &mut R,
1205        options: RawPackInstallOptions,
1206        progress: F,
1207    ) -> Result<PackInstallResult>
1208    where
1209        R: Read,
1210        F: FnMut(PackInstallProgress),
1211    {
1212        self.install_raw_pack_from_reader_with_progress_and_cancel(
1213            reader,
1214            options,
1215            CancelFlag::never(),
1216            progress,
1217        )
1218    }
1219
1220    /// Install a raw pack stream with cooperative cancellation and progress.
1221    ///
1222    /// Polls `cancel` between parallel indexing jobs and while receiving into
1223    /// the bounded spool, so a trip during either stage aborts promptly.
1224    /// On any failure — including [`GitError::Cancelled`] — the temporary pack
1225    /// staging file under `objects/pack` is removed.
1226    pub fn install_raw_pack_from_reader_with_progress_and_cancel<R, F>(
1227        &self,
1228        reader: &mut R,
1229        options: RawPackInstallOptions,
1230        cancel: CancelFlag<'_>,
1231        progress: F,
1232    ) -> Result<PackInstallResult>
1233    where
1234        R: Read,
1235        F: FnMut(PackInstallProgress),
1236    {
1237        // Fail before creating a temp file when cancel is already set.
1238        cancel.check()?;
1239        let pack_dir = self.objects_dir.join("pack");
1240        fs::create_dir_all(&pack_dir)?;
1241        let temp_pack_path = unique_temp_path(&pack_dir).with_extension("pack");
1242        let result = (|| -> Result<PackInstallResult> {
1243            // Stage directly in objects/pack so validation, mmap indexing, and
1244            // the checksum-named rename all use one immutable file.
1245            let file = fs::OpenOptions::new()
1246                .write(true)
1247                .create_new(true)
1248                .open(&temp_pack_path)?;
1249            let mut progress = progress;
1250            let receive =
1251                receive_pack_to_file(reader, file, options.max_input_size, cancel, &mut progress)
1252                    .map_err(map_install_cancel_error)?;
1253            let built = {
1254                let mapped = sley_mmap::MappedFile::open_pack(&temp_pack_path)?;
1255                PackIndex::write_v2_for_pack_with_options(
1256                    mapped.as_bytes(),
1257                    self.format,
1258                    |_| Ok(None),
1259                    sley_pack::PackIndexOptions::default(),
1260                    cancel,
1261                    |indexed: PackIndexProgress| {
1262                        progress(PackInstallProgress {
1263                            received_bytes: receive.bytes,
1264                            indexed_objects: indexed.completed_objects,
1265                            total_objects: indexed.total_objects,
1266                        });
1267                    },
1268                )?
1269            };
1270
1271            self.install_pack_file_from_temp(
1272                &temp_pack_path,
1273                built.pack_checksum,
1274                &built.index,
1275                built.entries.iter().map(|entry| entry.oid).collect(),
1276                options,
1277            )
1278        })();
1279        if result.is_err() {
1280            let _ = fs::remove_file(&temp_pack_path);
1281        }
1282        result
1283    }
1284}
1285
1286pub(crate) fn write_pack_component(path: &Path, bytes: &[u8]) -> Result<()> {
1287    if path.exists() {
1288        return Ok(());
1289    }
1290    let parent = path
1291        .parent()
1292        .ok_or_else(|| GitError::InvalidPath("pack component path has no parent".into()))?;
1293    fs::create_dir_all(parent)?;
1294    let temp_path = unique_temp_path(parent);
1295    let write_result = (|| -> Result<()> {
1296        {
1297            let mut file = fs::OpenOptions::new()
1298                .write(true)
1299                .create_new(true)
1300                .open(&temp_path)?;
1301            #[cfg(feature = "fetch-profile")]
1302            let _profile_span = sley_core::fetch_profile::Span::enter(
1303                sley_core::fetch_profile::Stage::ObjectStoreWrite,
1304            );
1305            file.write_all(bytes)?;
1306            file.sync_all()?;
1307            #[cfg(feature = "fetch-profile")]
1308            {
1309                sley_core::fetch_profile::add_count(
1310                    sley_core::fetch_profile::Stage::ObjectStoreWrite,
1311                    1,
1312                );
1313                sley_core::fetch_profile::add_bytes(
1314                    sley_core::fetch_profile::Stage::ObjectStoreWrite,
1315                    bytes.len() as u64,
1316                );
1317                sley_core::fetch_profile::add_fsync();
1318            }
1319        }
1320        match fs::rename(&temp_path, path) {
1321            Ok(()) => Ok(()),
1322            Err(_) if path.exists() => {
1323                let _ = fs::remove_file(&temp_path);
1324                Ok(())
1325            }
1326            Err(err) => Err(GitError::Io(err.to_string())),
1327        }
1328    })();
1329    if write_result.is_err() {
1330        let _ = fs::remove_file(&temp_path);
1331    }
1332    write_result
1333}
1334
1335/// Write a mutable pack sidecar through a completed temporary file, replacing
1336/// an existing destination. Unix can rename over the destination atomically;
1337/// platforms which reject that operation fall back to removing the old file
1338/// only after the replacement has been fully written and synced.
1339pub(crate) fn replace_pack_component(path: &Path, bytes: &[u8]) -> Result<()> {
1340    let parent = path
1341        .parent()
1342        .ok_or_else(|| GitError::InvalidPath("pack component path has no parent".into()))?;
1343    fs::create_dir_all(parent)?;
1344    let temp_path = unique_temp_path(parent);
1345    let write_result = (|| -> Result<()> {
1346        {
1347            let mut file = fs::OpenOptions::new()
1348                .write(true)
1349                .create_new(true)
1350                .open(&temp_path)?;
1351            file.write_all(bytes)?;
1352            file.sync_all()?;
1353        }
1354        match fs::rename(&temp_path, path) {
1355            Ok(()) => Ok(()),
1356            Err(_) if path.exists() => {
1357                fs::remove_file(path)?;
1358                fs::rename(&temp_path, path)?;
1359                Ok(())
1360            }
1361            Err(err) => Err(GitError::Io(err.to_string())),
1362        }
1363    })();
1364    if write_result.is_err() {
1365        let _ = fs::remove_file(&temp_path);
1366    }
1367    write_result
1368}
1369
1370pub(crate) fn write_promisor_pack_sidecar(
1371    pack_dir: &Path,
1372    pack_name: &str,
1373    promisor: bool,
1374) -> Result<Option<PathBuf>> {
1375    if !promisor {
1376        return Ok(None);
1377    }
1378    let path = pack_dir.join(format!("{pack_name}.promisor"));
1379    write_pack_component(&path, b"")?;
1380    Ok(Some(path))
1381}