This repository has no description
18 kB
529 lines
1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::AtomicBool;
4use std::time::{Duration, SystemTime};
5
6use gix::progress::Discard;
7use knot_types::{Oid, UnixSeconds};
8
9use crate::fsio::{self, PackStem};
10use crate::{FileCount, MaintError, ObjectCount, PruneReport};
11
12const MTIMES_MAGIC: u32 = 0x4d54_4d45;
13const MTIMES_VERSION: u32 = 1;
14
15pub fn run(
16 objects_dir: &Path,
17 kind: gix::hash::Kind,
18 reachable: &HashSet<Oid>,
19 new_reachable_stem: Option<&PackStem>,
20 kept_large: &[PackStem],
21 loose: &[(Oid, PathBuf)],
22 grace: Duration,
23) -> Result<PruneReport, MaintError> {
24 let pack_dir = objects_dir.join("pack");
25 let idxs = fsio::pack_idx_paths(objects_dir);
26 let now = SystemTime::now();
27 let now_mtime = PackedMtime::from_system_time(now);
28
29 let kept_pack =
30 |stem: &PackStem| -> bool { Some(stem) == new_reachable_stem || kept_large.contains(stem) };
31
32 let kept_pack_oids: HashSet<Oid> = idxs
33 .iter()
34 .filter(|idx| PackStem::of(idx).is_some_and(|stem| kept_pack(&stem)))
35 .filter_map(|idx| fsio::pack_oids(idx, kind))
36 .flatten()
37 .collect();
38
39 let recorded = read_recorded_mtimes(&idxs, kind);
40 let mtime_of = mtime_index(&idxs, &pack_dir, kind, &recorded, loose);
41
42 let unreachable =
43 unreachable_candidates(&idxs, kind, loose, reachable, &kept_pack_oids, &kept_pack);
44 let mut keep: Vec<Oid> = unreachable
45 .into_iter()
46 .filter(|oid| !is_expired(mtime_of.get(oid), now, grace))
47 .collect();
48 keep.sort();
49
50 let cruft_stem = if keep.is_empty() {
51 None
52 } else {
53 let stem = write_cruft_pack(objects_dir, keep.clone(), kind)?;
54 if let Some(stem) = &stem {
55 write_mtimes(&pack_dir, stem, kind, |oid| {
56 mtime_of
57 .get(&oid)
58 .map(PackedMtime::from_unix)
59 .unwrap_or(now_mtime)
60 })?;
61 }
62 stem
63 };
64 knot_resource::fsync_path(&pack_dir)?;
65
66 let kept_oids: HashSet<Oid> = kept_pack_oids
67 .iter()
68 .copied()
69 .chain(cruft_stem.as_ref().into_iter().flat_map(|stem| {
70 fsio::pack_oids(&stem.file(&pack_dir, "idx"), kind).unwrap_or_default()
71 }))
72 .collect();
73
74 let keep_covered = keep.iter().all(|oid| kept_oids.contains(oid));
75 if !closure_is_covered(reachable, &kept_oids) || !keep_covered {
76 if let Some(stem) = cruft_stem.as_ref() {
77 fsio::remove_pack_files(&stem.file(&pack_dir, "idx"), &pack_dir);
78 }
79 return Ok(PruneReport::skipped());
80 }
81
82 let removed_packs = idxs
83 .iter()
84 .filter(|idx| {
85 PackStem::of(idx)
86 .is_some_and(|stem| !kept_pack(&stem) && Some(&stem) != cruft_stem.as_ref())
87 })
88 .filter(|idx| fsio::remove_pack_files(idx, &pack_dir))
89 .count();
90 let removed_loose = loose
91 .iter()
92 .filter(|(oid, _)| !reachable.contains(oid))
93 .filter(|(_, path)| std::fs::remove_file(path).is_ok())
94 .count();
95 knot_resource::fsync_path(&pack_dir)?;
96 knot_resource::fsync_path(objects_dir)?;
97
98 Ok(PruneReport {
99 removed: FileCount::new(removed_loose),
100 removed_packs: FileCount::new(removed_packs),
101 crufted: ObjectCount::new(keep.len()),
102 ran: true,
103 })
104}
105
106fn closure_is_covered(reachable: &HashSet<Oid>, kept: &HashSet<Oid>) -> bool {
107 reachable.is_subset(kept)
108}
109
110fn unreachable_candidates<K: Fn(&PackStem) -> bool>(
111 idxs: &[PathBuf],
112 kind: gix::hash::Kind,
113 loose: &[(Oid, PathBuf)],
114 reachable: &HashSet<Oid>,
115 kept_pack_oids: &HashSet<Oid>,
116 kept_pack: &K,
117) -> Vec<Oid> {
118 idxs.iter()
119 .filter(|idx| PackStem::of(idx).is_some_and(|stem| !kept_pack(&stem)))
120 .filter_map(|idx| fsio::pack_oids(idx, kind))
121 .flatten()
122 .chain(loose.iter().map(|(oid, _)| *oid))
123 .filter(|oid| !reachable.contains(oid))
124 .filter(|oid| !kept_pack_oids.contains(oid))
125 .collect::<HashSet<Oid>>()
126 .into_iter()
127 .collect()
128}
129
130fn mtime_index(
131 idxs: &[PathBuf],
132 pack_dir: &Path,
133 kind: gix::hash::Kind,
134 recorded: &HashMap<Oid, UnixSeconds>,
135 loose: &[(Oid, PathBuf)],
136) -> HashMap<Oid, UnixSeconds> {
137 let from_packs = idxs.iter().flat_map(|idx| {
138 let is_cruft = idx.with_extension("mtimes").exists();
139 let pack_secs = fsio::pack_mtime(idx, pack_dir);
140 fsio::pack_oids(idx, kind)
141 .unwrap_or_default()
142 .into_iter()
143 .filter_map(move |oid| {
144 let secs = if is_cruft {
145 recorded.get(&oid).copied()
146 } else {
147 pack_secs
148 };
149 secs.map(|secs| (oid, secs))
150 })
151 });
152 let from_loose = loose
153 .iter()
154 .filter_map(|(oid, path)| loose_mtime(path).map(|secs| (*oid, secs)));
155 newest_by_oid(from_packs.chain(from_loose))
156}
157
158fn newest_by_oid(pairs: impl Iterator<Item = (Oid, UnixSeconds)>) -> HashMap<Oid, UnixSeconds> {
159 pairs.fold(HashMap::new(), |mut acc, (oid, secs)| {
160 acc.entry(oid)
161 .and_modify(|current| {
162 if secs.get() > current.get() {
163 *current = secs;
164 }
165 })
166 .or_insert(secs);
167 acc
168 })
169}
170
171fn read_recorded_mtimes(idxs: &[PathBuf], kind: gix::hash::Kind) -> HashMap<Oid, UnixSeconds> {
172 idxs.iter()
173 .filter(|idx| idx.with_extension("mtimes").exists())
174 .filter_map(|idx| {
175 let bytes = std::fs::read(idx.with_extension("mtimes")).ok()?;
176 let oids = fsio::pack_oids(idx, kind)?;
177 let checksum = gix_pack::data::File::at(idx.with_extension("pack"), kind)
178 .ok()?
179 .checksum();
180 let table = validated_mtimes(&bytes, oids.len(), kind, checksum.as_slice())?;
181 Some(
182 oids.into_iter()
183 .zip(table)
184 .map(|(oid, mtime)| (oid, mtime.to_unix()))
185 .collect::<Vec<_>>(),
186 )
187 })
188 .flatten()
189 .collect()
190}
191
192fn validated_mtimes(
193 bytes: &[u8],
194 count: usize,
195 kind: gix::hash::Kind,
196 pack_checksum: &[u8],
197) -> Option<Vec<PackedMtime>> {
198 let hash_len = kind.len_in_bytes();
199 let header = 12usize;
200 let total = header + count * 4 + hash_len * 2;
201 if bytes.len() != total
202 || bytes[0..4] != MTIMES_MAGIC.to_be_bytes()
203 || bytes[4..8] != MTIMES_VERSION.to_be_bytes()
204 || bytes[8..12] != hash_id(kind).to_be_bytes()
205 {
206 return None;
207 }
208 let table_end = header + count * 4;
209 if bytes[table_end..table_end + hash_len] != *pack_checksum {
210 return None;
211 }
212 let mut hasher = gix_hash::hasher(kind);
213 hasher.update(&bytes[..total - hash_len]);
214 let digest = hasher.try_finalize().ok()?;
215 if digest.as_slice() != &bytes[total - hash_len..] {
216 return None;
217 }
218 Some(
219 (0..count)
220 .map(|index| {
221 let offset = header + index * 4;
222 PackedMtime(u32::from_be_bytes(
223 bytes[offset..offset + 4].try_into().unwrap(),
224 ))
225 })
226 .collect(),
227 )
228}
229
230fn is_expired(mtime: Option<&UnixSeconds>, now: SystemTime, grace: Duration) -> bool {
231 let Some(mtime) = mtime else {
232 return false;
233 };
234 let when = SystemTime::UNIX_EPOCH + Duration::from_secs(mtime.get().max(0) as u64);
235 now.duration_since(when)
236 .map(|age| age >= grace)
237 .unwrap_or(false)
238}
239
240fn loose_mtime(path: &Path) -> Option<UnixSeconds> {
241 let modified = path.metadata().ok()?.modified().ok()?;
242 Some(PackedMtime::from_system_time(modified).to_unix())
243}
244
245#[derive(Debug, Clone, Copy)]
246struct PackedMtime(u32);
247
248impl PackedMtime {
249 fn from_unix(secs: &UnixSeconds) -> Self {
250 Self(secs.get().clamp(0, u32::MAX as i64) as u32)
251 }
252
253 fn from_system_time(time: SystemTime) -> Self {
254 Self(
255 time.duration_since(SystemTime::UNIX_EPOCH)
256 .map(|delta| delta.as_secs().min(u32::MAX as u64) as u32)
257 .unwrap_or(0),
258 )
259 }
260
261 fn to_unix(self) -> UnixSeconds {
262 UnixSeconds::new(self.0 as i64)
263 }
264
265 fn to_be_bytes(self) -> [u8; 4] {
266 self.0.to_be_bytes()
267 }
268}
269
270fn hash_id(kind: gix::hash::Kind) -> u32 {
271 match kind {
272 gix::hash::Kind::Sha256 => 2,
273 _ => 1,
274 }
275}
276
277fn write_cruft_pack(
278 objects_dir: &Path,
279 oids: Vec<Oid>,
280 kind: gix::hash::Kind,
281) -> Result<Option<PackStem>, MaintError> {
282 let pack_dir = objects_dir.join("pack");
283 std::fs::create_dir_all(&pack_dir).map_err(|error| fsio::io_error(&pack_dir, error))?;
284 knot_resource::clear_stale(&pack_dir, ".knot-cruft.");
285 let staging = pack_dir.join(format!(
286 ".knot-cruft.{}.pack",
287 knot_resource::staging_nonce()
288 ));
289 let outcome = stream_pack(objects_dir, oids, kind, &staging)
290 .and_then(|()| install_pack(&pack_dir, &staging, kind));
291 let _ = std::fs::remove_file(&staging);
292 outcome
293}
294
295fn stream_pack(
296 objects_dir: &Path,
297 oids: Vec<Oid>,
298 kind: gix::hash::Kind,
299 staging: &Path,
300) -> Result<(), MaintError> {
301 let file = std::fs::File::create(staging).map_err(|error| fsio::io_error(staging, error))?;
302 let mut writer = std::io::BufWriter::new(file);
303 knot_pack::write_pack(objects_dir, oids, None, &mut writer, kind)
304 .map_err(|error| MaintError::Pack(error.to_string()))?;
305 writer
306 .into_inner()
307 .map(|_| ())
308 .map_err(|error| fsio::io_error(staging, error.into_error()))
309}
310
311fn install_pack(
312 pack_dir: &Path,
313 staging: &Path,
314 kind: gix::hash::Kind,
315) -> Result<Option<PackStem>, MaintError> {
316 let file = std::fs::File::open(staging).map_err(|error| fsio::io_error(staging, error))?;
317 let mut reader = std::io::BufReader::new(file);
318 let outcome = gix_pack::Bundle::write_to_directory(
319 &mut reader,
320 Some(pack_dir),
321 &mut Discard,
322 &AtomicBool::new(false),
323 None::<gix::odb::Handle>,
324 gix_pack::bundle::write::Options {
325 thread_limit: Some(1),
326 iteration_mode: gix_pack::data::input::Mode::Verify,
327 index_version: gix_pack::index::Version::default(),
328 object_hash: kind,
329 },
330 )
331 .map_err(|error| MaintError::Pack(error.to_string()))?;
332
333 if let Some(keep) = &outcome.keep_path {
334 let _ = std::fs::remove_file(keep);
335 }
336 [&outcome.data_path, &outcome.index_path]
337 .into_iter()
338 .flatten()
339 .try_for_each(|path| knot_resource::fsync_path(path))?;
340
341 Ok(outcome
342 .data_path
343 .as_ref()
344 .and_then(|path| PackStem::of(path)))
345}
346
347fn write_mtimes(
348 pack_dir: &Path,
349 stem: &PackStem,
350 kind: gix::hash::Kind,
351 mtime_for: impl Fn(Oid) -> PackedMtime,
352) -> Result<(), MaintError> {
353 let idx = stem.file(pack_dir, "idx");
354 let pack = stem.file(pack_dir, "pack");
355 let index = gix_pack::index::File::at(&idx, kind)
356 .map_err(|error| MaintError::Pack(format!("open cruft index: {error}")))?;
357 let checksum = gix_pack::data::File::at(&pack, kind)
358 .map_err(|error| MaintError::Pack(format!("open cruft pack: {error}")))?
359 .checksum();
360
361 let mut out = Vec::new();
362 out.extend_from_slice(&MTIMES_MAGIC.to_be_bytes());
363 out.extend_from_slice(&MTIMES_VERSION.to_be_bytes());
364 out.extend_from_slice(&hash_id(kind).to_be_bytes());
365 index
366 .iter()
367 .for_each(|entry| out.extend_from_slice(&mtime_for(Oid::from(entry.oid)).to_be_bytes()));
368 out.extend_from_slice(checksum.as_slice());
369 let mut hasher = gix_hash::hasher(kind);
370 hasher.update(&out);
371 let digest = hasher
372 .try_finalize()
373 .map_err(|error| MaintError::Pack(format!("cruft mtimes checksum: {error}")))?;
374 out.extend_from_slice(digest.as_slice());
375
376 knot_resource::atomic_write_bytes(
377 &stem.file(pack_dir, "mtimes"),
378 &out,
379 knot_resource::FileMode::Inherited,
380 )?;
381 Ok(())
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 fn oid(byte: u8) -> Oid {
389 Oid::from_hex(&format!("{byte:02x}").repeat(20)).unwrap()
390 }
391
392 #[test]
393 fn closure_covered_when_every_reachable_oid_survives() {
394 let reachable: HashSet<Oid> = [oid(1), oid(2)].into_iter().collect();
395 let kept: HashSet<Oid> = [oid(1), oid(2), oid(3)].into_iter().collect();
396 assert!(closure_is_covered(&reachable, &kept));
397 }
398
399 #[test]
400 fn closure_uncovered_when_a_reachable_oid_is_missing() {
401 let reachable: HashSet<Oid> = [oid(1), oid(2)].into_iter().collect();
402 let kept: HashSet<Oid> = [oid(1)].into_iter().collect();
403 assert!(!closure_is_covered(&reachable, &kept));
404 }
405
406 #[test]
407 fn unknown_mtime_is_never_expired() {
408 assert!(!is_expired(None, SystemTime::now(), Duration::ZERO));
409 }
410
411 #[test]
412 fn future_mtime_is_never_expired() {
413 let future = PackedMtime::from_system_time(SystemTime::now())
414 .to_unix()
415 .saturating_add_secs(100_000);
416 assert!(!is_expired(
417 Some(&future),
418 SystemTime::now(),
419 Duration::from_secs(1)
420 ));
421 }
422
423 #[test]
424 fn newest_mtime_wins_regardless_of_iteration_order() {
425 let shared = oid(7);
426 let old = UnixSeconds::new(1_000);
427 let fresh = UnixSeconds::new(2_000);
428 let forward = newest_by_oid([(shared, old), (shared, fresh)].into_iter());
429 let reverse = newest_by_oid([(shared, fresh), (shared, old)].into_iter());
430 assert_eq!(forward.get(&shared), Some(&fresh));
431 assert_eq!(reverse.get(&shared), Some(&fresh));
432 }
433
434 #[test]
435 fn old_mtime_past_grace_is_expired() {
436 let old = UnixSeconds::new(1_000);
437 assert!(is_expired(
438 Some(&old),
439 SystemTime::now(),
440 Duration::from_secs(60)
441 ));
442 }
443
444 #[test]
445 fn corrupt_mtimes_are_rejected_rather_than_trusted() {
446 let count = 3usize;
447 let hash_len = gix::hash::Kind::Sha1.len_in_bytes();
448 let total = 12 + count * 4 + hash_len * 2;
449 let zero_checksum = vec![0u8; hash_len];
450 let unsigned = vec![0u8; total];
451 assert!(
452 validated_mtimes(&unsigned, count, gix::hash::Kind::Sha1, &zero_checksum).is_none(),
453 "a correctly-sized but unsigned mtimes table isn't trusted"
454 );
455 let truncated = vec![0u8; total - 1];
456 assert!(
457 validated_mtimes(&truncated, count, gix::hash::Kind::Sha1, &zero_checksum).is_none()
458 );
459 let mut wrong_pack = vec![0u8; total];
460 wrong_pack[0..4].copy_from_slice(&MTIMES_MAGIC.to_be_bytes());
461 wrong_pack[4..8].copy_from_slice(&MTIMES_VERSION.to_be_bytes());
462 wrong_pack[8..12].copy_from_slice(&hash_id(gix::hash::Kind::Sha1).to_be_bytes());
463 let mismatched = vec![0xabu8; hash_len];
464 assert!(
465 validated_mtimes(&wrong_pack, count, gix::hash::Kind::Sha1, &mismatched).is_none(),
466 "an mtimes table whose pack checksum names a different pack isn't trusted"
467 );
468 }
469
470 #[test]
471 fn run_fail_closes_when_survivors_miss_the_closure() {
472 use knot_git::{Layout, RefUpdate};
473 use knot_types::{BranchName, RefName, RepoDid};
474
475 use crate::test_support::{commit_on, empty_tree};
476
477 let scan = tempfile::tempdir().unwrap();
478 let layout = Layout::new(scan.path()).with_default_branch(BranchName::new("main").unwrap());
479 let did = RepoDid::new("did:plc:limpet").unwrap();
480 let repo = layout.create(&did).unwrap();
481 let tip = commit_on(&repo, empty_tree(repo.object_format()), Vec::new(), "a");
482 repo.update_ref(&RefUpdate::Create {
483 name: RefName::new("refs/heads/main").unwrap(),
484 new: tip,
485 })
486 .unwrap();
487
488 let options = crate::Options {
489 repack_max_objects: crate::ObjectCount::new(1_000_000),
490 geometric_factor: crate::GeometricFactor::full_repack(),
491 prune_grace: crate::PruneGrace::from_secs(0),
492 reflog_floor: crate::ReflogRetention::from_secs(i64::MAX as u64 / 4),
493 commit_graph: false,
494 multi_pack_index: false,
495 bitmap: false,
496 };
497 crate::run_repo(&repo, UnixSeconds::new(1_700_000_500), &options).unwrap();
498
499 let objects_dir = repo.objects_dir();
500 let kind = repo.object_format().kind();
501 let reachable: HashSet<Oid> = repo
502 .select_pack_objects(knot_git::Wants::new(&[tip]), knot_git::Haves::new(&[]))
503 .unwrap()
504 .into_iter()
505 .collect();
506 assert!(!reachable.is_empty());
507
508 let absent = PackStem::of(Path::new(
509 "pack-0000000000000000000000000000000000000000.idx",
510 ))
511 .unwrap();
512 let report = run(
513 &objects_dir,
514 kind,
515 &reachable,
516 Some(&absent),
517 &[],
518 &[],
519 Duration::ZERO,
520 )
521 .unwrap();
522 assert!(!report.ran, "an uncovered closure fail-closes the prune");
523 let reopened = knot_git::Repo::open(repo.git().git_dir()).unwrap();
524 assert!(
525 reachable.iter().all(|oid| reopened.contains(*oid)),
526 "no reachable object is deleted when survivors don't cover the closure"
527 );
528 }
529}