girt 0.7.0

An incremental, idiomatic Rust implementation of Git
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
//! Regenerate a pack's `.idx` from the pack alone and replace the old index atomically.
use std::fs::File;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;

use crate::fetch::{Bytes, FetchError, FetchLimits, index_self_contained};
use crate::pack::index::{Index, verify_hash};
use crate::{ObjectFormat, ObjectId, ObjectReadError};

/// Bounds the work [`rebuild_index`] may do while it verifies and resolves a pack.
///
/// Every limit applies to the whole pack. The defaults admit packs Git writes with ordinary
/// settings up to 256 MiB; raise them for larger packs or deeper delta chains (Git's aggressive
/// repack uses depth 250).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IndexRebuildLimits {
    /// Maximum `.pack` file size.
    pub max_pack_bytes: usize,
    /// Maximum object count in the pack header.
    pub max_objects: usize,
    /// Maximum inflated size of one non-delta object or reconstructed delta result.
    pub max_object_bytes: usize,
    /// Maximum inflated size of one delta program.
    pub max_delta_bytes: usize,
    /// Maximum total inflated and reconstructed bytes across the pack.
    pub max_decode_bytes: usize,
    /// Maximum delta chain depth.
    pub max_delta_depth: usize,
    /// Maximum delta resolution steps.
    pub max_resolution_steps: usize,
}

impl Default for IndexRebuildLimits {
    fn default() -> Self {
        let fetch = FetchLimits::default();
        Self {
            max_pack_bytes: fetch.max_pack_bytes,
            max_objects: fetch.max_objects,
            max_object_bytes: fetch.max_object_bytes,
            max_delta_bytes: fetch.max_delta_bytes,
            max_decode_bytes: fetch.max_decode_bytes,
            max_delta_depth: fetch.max_delta_depth,
            max_resolution_steps: fetch.max_resolution_steps,
        }
    }
}

impl IndexRebuildLimits {
    fn fetch(self) -> FetchLimits {
        FetchLimits {
            max_pack_bytes: self.max_pack_bytes,
            max_objects: self.max_objects,
            max_object_bytes: self.max_object_bytes,
            max_delta_bytes: self.max_delta_bytes,
            max_decode_bytes: self.max_decode_bytes,
            max_delta_depth: self.max_delta_depth,
            max_resolution_steps: self.max_resolution_steps,
            ..FetchLimits::default()
        }
    }
}

/// What [`rebuild_index`] did to the index file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexRebuildOutcome {
    /// No index existed; the rebuilt one was published.
    Created,
    /// The existing index differed and was replaced.
    Replaced,
    /// The existing index already matched byte for byte; no file was written.
    Unchanged,
}

/// The result of a successful [`rebuild_index`].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct IndexRebuilt {
    /// The pack checksum, which is also the index trailer's pack hash.
    pub checksum: ObjectId,
    /// Objects described by the index.
    pub objects: usize,
    /// The index path.
    pub index: PathBuf,
    /// Whether the index was created, replaced or already correct.
    pub outcome: IndexRebuildOutcome,
}

/// Errors from [`rebuild_index`].
///
/// The `.pack` is never changed. Every error except [`Self::Unsynced`] leaves any existing `.idx`
/// unchanged.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum IndexRebuildError {
    /// The path does not name a `*.pack` file.
    #[error("not a .pack path: {0}")]
    NotPack(PathBuf),
    /// The pack uses a version this library does not index; only version 2 is supported.
    #[error("unsupported pack version {0}")]
    PackVersion(u32),
    /// The pack failed verification: its trailer, an entry, a delta or an identity.
    #[error("pack verification")]
    Corrupt(#[source] ObjectReadError),
    /// A rebuild limit was exhausted.
    #[error("index rebuild limit exceeded: {0}")]
    Limit(&'static str),
    /// The cancellation flag was set.
    #[error("index rebuild cancelled")]
    Cancelled,
    /// Reading the pack or writing the index failed before publication.
    #[error("index rebuild I/O")]
    Io(#[source] io::Error),
    /// The rebuilt index was renamed into place, but syncing the directory failed, so the
    /// rename may not survive a crash. Either index then describes the same pack; retrying is
    /// safe and reports [`IndexRebuildOutcome::Unchanged`] once the new index is in place.
    #[error("rebuilt index published, but the pack directory sync failed")]
    Unsynced {
        /// The published index, as a successful rebuild would have reported it.
        rebuilt: Box<IndexRebuilt>,
        /// The directory sync failure.
        #[source]
        source: io::Error,
    },
}

impl From<io::Error> for IndexRebuildError {
    fn from(error: io::Error) -> Self {
        Self::Io(error)
    }
}

impl From<FetchError> for IndexRebuildError {
    fn from(error: FetchError) -> Self {
        match error {
            FetchError::Pack(ObjectReadError::Limit(limit)) | FetchError::Limit(limit) => {
                Self::Limit(limit)
            }
            FetchError::Pack(ObjectReadError::Cancelled) | FetchError::Cancelled => Self::Cancelled,
            FetchError::Pack(ObjectReadError::PackVersion(version)) => Self::PackVersion(version),
            FetchError::Pack(error) => Self::Corrupt(error),
            FetchError::Io(error) => Self::Io(error),
            FetchError::Index(_) => Self::Limit("index encoding"),
            other => Self::Io(io::Error::other(other)),
        }
    }
}

/// Regenerates the v2 `.idx` beside `pack` and atomically replaces any existing index.
///
/// A pack's index is derived data, so a damaged or missing index never loses objects. This
/// reads a private copy of the pack, checks its trailer hash, the framing and CRC of every
/// entry, decodes every object and resolves every delta inside the pack. Then it encodes the
/// index exactly as `git index-pack` does by default (version 2, 64-bit offsets for entries at or
/// past 2 GiB) and parses it back before publication. The new index is written to a
/// `tmp_idx_*` file in the pack directory and synced, renamed over `<stem>.idx`, and the
/// directory is synced. Readers therefore see the old or the new complete index, never a
/// partial one. If the existing index already matches byte for byte, nothing is written.
///
/// The `.pack` is only read. `format` must be the repository's object format; a mismatch fails
/// as [`IndexRebuildError::Corrupt`]. A thin pack (a delta whose base is outside the pack) is
/// refused, because a stored pack must be self-contained.
///
/// Do not run this concurrently with maintenance that retires the same pack: like
/// `git index-pack`, a rename after the retirement can leave an `.idx` without its `.pack`. No
/// object data is lost; removing that index restores the store.
///
/// # Errors
///
/// Returns [`IndexRebuildError::Corrupt`] when the pack fails verification,
/// [`IndexRebuildError::PackVersion`] for a pack other than version 2 (Git also indexes version
/// 3), and limit, cancellation or I/O errors. On these errors any existing index is unchanged;
/// at most a `tmp_pack_*` or `tmp_idx_*` file can remain after a crash, which Git ignores.
/// [`IndexRebuildError::Unsynced`] reports that the new index was published but its directory
/// entry may not be durable.
pub fn rebuild_index(
    pack: &Path,
    format: ObjectFormat,
    limits: IndexRebuildLimits,
    cancel: &AtomicBool,
) -> Result<IndexRebuilt, IndexRebuildError> {
    rebuild_with(pack, format, limits, cancel, &Publish::default())
}

/// Publication steps, replaceable in tests to inject faults on either side of the rename.
struct Publish {
    rename: fn(tempfile::NamedTempFile, &Path) -> io::Result<()>,
    sync_directory: fn(&Path) -> io::Result<()>,
}

impl Default for Publish {
    fn default() -> Self {
        Self {
            rename: |temporary, path| {
                // Windows refuses to replace a read-only file, and Git writes indexes read-only.
                #[cfg(windows)]
                if let Ok(metadata) = std::fs::metadata(path) {
                    let mut permissions = metadata.permissions();
                    if permissions.readonly() {
                        #[allow(clippy::permissions_set_readonly_false)]
                        permissions.set_readonly(false);
                        std::fs::set_permissions(path, permissions)?;
                    }
                }
                // `std::fs::rename` replaces with POSIX semantics where Windows supports them, so
                // readers holding the old index open do not block the replacement.
                // Disarm deletion before the rename: afterwards the temporary name is gone, and
                // Windows reports that as an error even though the index was published.
                let temporary = temporary
                    .into_temp_path()
                    .keep()
                    .map_err(|error| error.error)?;
                std::fs::rename(&temporary, path).inspect_err(|_| {
                    let _ = std::fs::remove_file(&temporary);
                })
            },
            sync_directory: |directory| {
                #[cfg(unix)]
                File::open(directory)?.sync_all()?;
                #[cfg(not(unix))]
                let _ = directory;
                Ok(())
            },
        }
    }
}

const CHUNK: usize = 64 * 1024;

fn rebuild_with(
    pack: &Path,
    format: ObjectFormat,
    limits: IndexRebuildLimits,
    cancel: &AtomicBool,
    publish: &Publish,
) -> Result<IndexRebuilt, IndexRebuildError> {
    if pack.extension().is_none_or(|extension| extension != "pack") {
        return Err(IndexRebuildError::NotPack(pack.into()));
    }
    check_cancelled(cancel)?;
    let directory = pack
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or(Path::new("."));
    let index_path = pack.with_extension("idx");
    // Verify a private copy so a concurrent writer to the pack cannot change what the index
    // describes after verification, and so nothing here can write through to the pack.
    let mut source = File::open(pack)?;
    let mode = source.metadata()?.permissions();
    let mut copy = tempfile::Builder::new()
        .prefix("tmp_pack_")
        .tempfile_in(directory)?;
    let mut copied = 0usize;
    let mut buffer = vec![0; CHUNK];
    loop {
        check_cancelled(cancel)?;
        let n = source.read(&mut buffer)?;
        if n == 0 {
            break;
        }
        copied += n;
        if copied > limits.max_pack_bytes {
            return Err(IndexRebuildError::Limit("pack bytes"));
        }
        copy.write_all(&buffer[..n])?;
    }
    let (index, checksum) =
        index_self_contained(format, Bytes::File(copy, copied), limits.fetch(), cancel)?;
    let parsed = Index::parse(format, &index, copied - format.digest_len())
        .map_err(IndexRebuildError::Corrupt)?;
    verify_hash(format, &index, "index checksum").map_err(IndexRebuildError::Corrupt)?;
    debug_assert_eq!(parsed.pack_hash, checksum);
    let mut rebuilt = IndexRebuilt {
        checksum,
        objects: parsed.len(),
        index: index_path.clone(),
        outcome: IndexRebuildOutcome::Unchanged,
    };
    rebuilt.outcome = match same_bytes(&index_path, &index, cancel)? {
        Some(true) => return Ok(rebuilt),
        Some(false) => IndexRebuildOutcome::Replaced,
        None => IndexRebuildOutcome::Created,
    };
    let mut temporary = tempfile::Builder::new()
        .prefix("tmp_idx_")
        .tempfile_in(directory)?;
    temporary.write_all(&index)?;
    // Git gives an index the same mode as its pack, which reflects `core.sharedRepository`.
    temporary.as_file().set_permissions(mode)?;
    temporary.as_file().sync_all()?;
    check_cancelled(cancel)?;
    (publish.rename)(temporary, &index_path)?;
    // The rename has happened; from here an error must say so rather than claim no change.
    (publish.sync_directory)(directory).map_err(|source| IndexRebuildError::Unsynced {
        rebuilt: Box::new(rebuilt.clone()),
        source,
    })?;
    Ok(rebuilt)
}

fn check_cancelled(cancel: &AtomicBool) -> Result<(), IndexRebuildError> {
    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
        Err(IndexRebuildError::Cancelled)
    } else {
        Ok(())
    }
}

/// Compares an existing index with `expected` without reading more than `expected.len()` bytes,
/// so an oversized damaged index costs no more than a correct one. `None` means it is absent.
fn same_bytes(
    path: &Path,
    expected: &[u8],
    cancel: &AtomicBool,
) -> Result<Option<bool>, IndexRebuildError> {
    let mut file = match File::open(path) {
        Ok(file) => file,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(error.into()),
    };
    if file.metadata()?.len() != expected.len() as u64 {
        return Ok(Some(false));
    }
    let mut buffer = vec![0; CHUNK];
    for chunk in expected.chunks(CHUNK) {
        check_cancelled(cancel)?;
        let actual = &mut buffer[..chunk.len()];
        match file.read_exact(actual) {
            Ok(()) if actual == chunk => {}
            Ok(()) => return Ok(Some(false)),
            Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(Some(false)),
            Err(error) => return Err(error.into()),
        }
    }
    Ok(Some(file.read(&mut [0])? == 0))
}

#[cfg(test)]
mod tests {
    use std::fs;

    use super::*;
    use crate::{ObjectKind, PackObject, PackWriteLimits, write_pack};

    /// Writes a one-blob SHA-1 pack and its index, then damages the index checksum.
    fn damaged() -> (tempfile::TempDir, PathBuf, Vec<u8>, Vec<u8>) {
        let directory = tempfile::tempdir().unwrap();
        let data = b"rebuild me";
        let object = PackObject {
            id: ObjectId::for_blob(ObjectFormat::Sha1, data),
            kind: ObjectKind::Blob,
            data,
        };
        let (mut pack, mut index) = (Vec::new(), Vec::new());
        write_pack(
            ObjectFormat::Sha1,
            &[object],
            &mut pack,
            &mut index,
            PackWriteLimits::default(),
        )
        .unwrap();
        let path = directory.path().join("pack-test.pack");
        fs::write(&path, &pack).unwrap();
        let mut bad = index.clone();
        *bad.last_mut().unwrap() ^= 1;
        fs::write(path.with_extension("idx"), &bad).unwrap();
        (directory, path, index, bad)
    }

    fn names(directory: &Path) -> Vec<String> {
        let mut names: Vec<_> = fs::read_dir(directory)
            .unwrap()
            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
            .collect();
        names.sort();
        names
    }

    fn rebuild(
        path: &Path,
        limits: IndexRebuildLimits,
        publish: &Publish,
    ) -> Result<IndexRebuilt, IndexRebuildError> {
        rebuild_with(
            path,
            ObjectFormat::Sha1,
            limits,
            &AtomicBool::new(false),
            publish,
        )
    }

    #[test]
    fn failed_rename_leaves_the_old_index_and_no_temporary_files() {
        let (directory, path, _, bad) = damaged();
        let before = names(directory.path());
        let publish = Publish {
            rename: |_, _| Err(io::Error::other("injected rename failure")),
            ..Publish::default()
        };

        let result = rebuild(&path, IndexRebuildLimits::default(), &publish);

        assert!(matches!(result, Err(IndexRebuildError::Io(_))));
        assert_eq!(fs::read(path.with_extension("idx")).unwrap(), bad);
        assert_eq!(names(directory.path()), before);
    }

    #[test]
    fn failed_directory_sync_reports_the_published_index() {
        let (_directory, path, good, _) = damaged();
        let publish = Publish {
            sync_directory: |_| Err(io::Error::other("injected sync failure")),
            ..Publish::default()
        };

        let result = rebuild(&path, IndexRebuildLimits::default(), &publish);

        let Err(IndexRebuildError::Unsynced { rebuilt, .. }) = result else {
            panic!("expected Unsynced, got {result:?}");
        };
        assert_eq!(rebuilt.outcome, IndexRebuildOutcome::Replaced);
        assert_eq!(fs::read(path.with_extension("idx")).unwrap(), good);
    }

    #[test]
    fn pack_byte_limit_is_exact_and_stops_the_copy() {
        let (directory, path, good, bad) = damaged();
        let before = names(directory.path());
        let len = fs::metadata(&path).unwrap().len() as usize;
        let limits = |max_pack_bytes| IndexRebuildLimits {
            max_pack_bytes,
            ..IndexRebuildLimits::default()
        };

        let refused = rebuild(&path, limits(len - 1), &Publish::default());
        assert!(matches!(
            refused,
            Err(IndexRebuildError::Limit("pack bytes"))
        ));
        assert_eq!(fs::read(path.with_extension("idx")).unwrap(), bad);
        assert_eq!(names(directory.path()), before);

        rebuild(&path, limits(len), &Publish::default()).unwrap();
        assert_eq!(fs::read(path.with_extension("idx")).unwrap(), good);
    }

    #[test]
    fn oversized_damaged_index_is_replaced_without_reading_it() {
        let (_directory, path, good, _) = damaged();
        // Sparse on the filesystems CI uses; reading it whole would need 4 GiB.
        File::options()
            .write(true)
            .open(path.with_extension("idx"))
            .unwrap()
            .set_len(1 << 32)
            .unwrap();

        let rebuilt = rebuild(&path, IndexRebuildLimits::default(), &Publish::default()).unwrap();

        assert_eq!(rebuilt.outcome, IndexRebuildOutcome::Replaced);
        assert_eq!(fs::read(path.with_extension("idx")).unwrap(), good);
    }

    #[test]
    fn index_comparison_is_bounded_by_the_expected_length() {
        let (directory, _, good, _) = damaged();
        let index = directory.path().join("compare.idx");
        let longer = [good.as_slice(), b"x"].concat();
        let cancel = AtomicBool::new(false);

        fs::write(&index, &good).unwrap();
        assert_eq!(same_bytes(&index, &good, &cancel).unwrap(), Some(true));
        fs::write(&index, &longer).unwrap();
        assert_eq!(same_bytes(&index, &good, &cancel).unwrap(), Some(false));
        fs::write(&index, &good[..good.len() - 1]).unwrap();
        assert_eq!(same_bytes(&index, &good, &cancel).unwrap(), Some(false));
        fs::remove_file(&index).unwrap();
        assert_eq!(same_bytes(&index, &good, &cancel).unwrap(), None);
    }

    /// Git 2.56.0 `index-pack` accepts this empty version-3 pack; girt reports it unsupported,
    /// not corrupt.
    #[test]
    fn version_3_pack_is_unsupported_not_corrupt() {
        let directory = tempfile::tempdir().unwrap();
        let mut pack = b"PACK\0\0\0\x03\0\0\0\0".to_vec();
        let mut hasher = crate::object::Hasher::new(ObjectFormat::Sha1);
        hasher.update(&pack);
        pack.extend_from_slice(hasher.finalize().as_bytes());
        let path = directory.path().join("pack-v3.pack");
        fs::write(&path, &pack).unwrap();

        let result = rebuild(&path, IndexRebuildLimits::default(), &Publish::default());

        assert!(matches!(result, Err(IndexRebuildError::PackVersion(3))));
        assert!(!path.with_extension("idx").exists());
    }
}