1use std::{
33 fs::{self, File, OpenOptions},
34 io::{self, Read, Write},
35 path::{Component, Path, PathBuf},
36 sync::atomic::{AtomicU64, Ordering},
37 thread,
38 time::{SystemTime, UNIX_EPOCH},
39};
40
41use serde::{Deserialize, Serialize};
42
43use crate::{
44 fault_inject,
45 fs_atomic::{
46 create_dir_all_durable, publish_file_durable, sync_directory, sync_file, temp_path,
47 write_file_atomic,
48 },
49 lock::RepoLock,
50 object::ContentHash,
51 store::snapshot_commit::snapshot_commit_marker_path,
52};
53
54pub const PACK_INSTALL_INTENT_VERSION: u32 = 2;
56
57pub const DEFAULT_PACK_INSTALL_INTENT_TTL_SECS: i64 = 86_400;
59
60pub const INTENT_CLOCK_SKEW_TOLERANCE_SECS: i64 = 300;
63
64const STAGING_DIR_NAME: &str = ".staging";
65const INTENT_DIR_NAME: &str = ".install-intent";
66const QUARANTINE_DIR_NAME: &str = "quarantine";
67const STAGED_PACK_NAME: &str = "pack";
68const STAGED_IDX_NAME: &str = "idx";
69const PACK_LOCKS_DIR_NAME: &str = ".pack-locks";
70const PACK_INSTALL_LOCK_NAME: &str = ".pack-install.lock";
72
73static METRIC_INSTALLS_OK: AtomicU64 = AtomicU64::new(0);
78static METRIC_INSTALLS_ERR: AtomicU64 = AtomicU64::new(0);
79static METRIC_RECOVER_COMPLETED: AtomicU64 = AtomicU64::new(0);
80static METRIC_RECOVER_ABORTED: AtomicU64 = AtomicU64::new(0);
81static METRIC_RECOVER_SKIPPED: AtomicU64 = AtomicU64::new(0);
82static METRIC_RECOVER_QUARANTINED: AtomicU64 = AtomicU64::new(0);
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
89pub struct PackInstallMetricsSnapshot {
90 pub installs_ok: u64,
91 pub installs_err: u64,
92 pub recover_completed: u64,
93 pub recover_aborted: u64,
94 pub recover_skipped_in_progress: u64,
95 pub recover_quarantined: u64,
96}
97
98pub fn pack_install_metrics_snapshot() -> PackInstallMetricsSnapshot {
100 PackInstallMetricsSnapshot {
101 installs_ok: METRIC_INSTALLS_OK.load(Ordering::Relaxed),
102 installs_err: METRIC_INSTALLS_ERR.load(Ordering::Relaxed),
103 recover_completed: METRIC_RECOVER_COMPLETED.load(Ordering::Relaxed),
104 recover_aborted: METRIC_RECOVER_ABORTED.load(Ordering::Relaxed),
105 recover_skipped_in_progress: METRIC_RECOVER_SKIPPED.load(Ordering::Relaxed),
106 recover_quarantined: METRIC_RECOVER_QUARANTINED.load(Ordering::Relaxed),
107 }
108}
109
110pub fn pack_install_metrics_reset() {
112 METRIC_INSTALLS_OK.store(0, Ordering::Relaxed);
113 METRIC_INSTALLS_ERR.store(0, Ordering::Relaxed);
114 METRIC_RECOVER_COMPLETED.store(0, Ordering::Relaxed);
115 METRIC_RECOVER_ABORTED.store(0, Ordering::Relaxed);
116 METRIC_RECOVER_SKIPPED.store(0, Ordering::Relaxed);
117 METRIC_RECOVER_QUARANTINED.store(0, Ordering::Relaxed);
118}
119
120fn metric_inc(counter: &AtomicU64) {
121 counter.fetch_add(1, Ordering::Relaxed);
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum PackInstallPhase {
128 Prepared,
129 PackPublished,
130 #[serde(other)]
132 Completed,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct PackInstallIntent {
138 pub version: u32,
139 pub install_id: String,
140 pub pack_name: String,
142 pub phase: PackInstallPhase,
143 pub created_unix: i64,
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct PackInstallRecoverReport {
149 pub intents_seen: u64,
150 pub completed: u64,
151 pub aborted: u64,
152 pub cleaned_stale_completed: u64,
153 pub skipped_in_progress: u64,
155 pub orphan_staging_swept: u64,
157 pub quarantined: u64,
159 pub errors: u64,
160}
161
162pub(crate) fn acquire_pack_name_lock(
164 packs_dir: &Path,
165 pack_name: &str,
166) -> io::Result<crate::lock::WriteLockGuard> {
167 validate_pack_name(pack_name)?;
168 create_dir_all_durable(packs_dir)?;
169 let locks = packs_dir.join(PACK_LOCKS_DIR_NAME);
170 create_dir_all_durable(&locks)?;
171 let lock = RepoLock::at(locks.join(format!("{pack_name}.lock")));
172 lock.write().map_err(|e| io::Error::other(e.to_string()))
173}
174
175pub(crate) fn try_acquire_pack_name_lock(
177 packs_dir: &Path,
178 pack_name: &str,
179) -> io::Result<Option<crate::lock::WriteLockGuard>> {
180 validate_pack_name(pack_name)?;
181 create_dir_all_durable(packs_dir)?;
182 let locks = packs_dir.join(PACK_LOCKS_DIR_NAME);
183 create_dir_all_durable(&locks)?;
184 let lock = RepoLock::at(locks.join(format!("{pack_name}.lock")));
185 lock.try_write()
186 .map_err(|e| io::Error::other(e.to_string()))
187}
188
189pub(crate) fn acquire_pack_install_lock(
191 packs_dir: &Path,
192) -> io::Result<crate::lock::WriteLockGuard> {
193 create_dir_all_durable(packs_dir)?;
194 let lock = RepoLock::at(packs_dir.join(PACK_INSTALL_LOCK_NAME));
195 lock.write().map_err(|e| io::Error::other(e.to_string()))
196}
197
198impl PackInstallIntent {
199 pub fn new(install_id: String, pack_name: String) -> Self {
200 Self {
201 version: PACK_INSTALL_INTENT_VERSION,
202 install_id,
203 pack_name,
204 phase: PackInstallPhase::Prepared,
205 created_unix: unix_now(),
206 }
207 }
208}
209
210fn unix_now() -> i64 {
215 SystemTime::now()
216 .duration_since(UNIX_EPOCH)
217 .map(|d| d.as_secs() as i64)
218 .unwrap_or(0)
219}
220
221pub(crate) fn validate_install_id(id: &str) -> io::Result<()> {
223 if id.is_empty() || id.len() > 128 {
224 return Err(io::Error::new(
225 io::ErrorKind::InvalidInput,
226 "invalid install_id length",
227 ));
228 }
229 if !id
230 .chars()
231 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
232 {
233 return Err(io::Error::new(
234 io::ErrorKind::InvalidInput,
235 "install_id contains illegal characters",
236 ));
237 }
238 if id == "." || id == ".." {
239 return Err(io::Error::new(
240 io::ErrorKind::InvalidInput,
241 "install_id must not be . or ..",
242 ));
243 }
244 Ok(())
245}
246
247pub(crate) fn validate_pack_name(name: &str) -> io::Result<()> {
249 pack_name_to_digest(name).map(|_| ())
250}
251
252pub(crate) fn pack_name_to_digest(name: &str) -> io::Result<[u8; 32]> {
256 if name.len() != 64 {
257 return Err(io::Error::new(
258 io::ErrorKind::InvalidInput,
259 "pack_name must be exactly 64 lowercase hex digits (BLAKE3)",
260 ));
261 }
262 if !name
263 .bytes()
264 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
265 {
266 return Err(io::Error::new(
267 io::ErrorKind::InvalidInput,
268 "pack_name must be lowercase hexadecimal",
269 ));
270 }
271 let mut digest = [0u8; 32];
272 hex::decode_to_slice(name.as_bytes(), &mut digest).map_err(|e| {
274 io::Error::new(
275 io::ErrorKind::InvalidInput,
276 format!("pack_name hex decode failed: {e}"),
277 )
278 })?;
279 Ok(digest)
280}
281
282fn digest_to_pack_name(digest: &[u8; 32]) -> String {
284 blake3::Hash::from_bytes(*digest).to_hex().to_string()
286}
287
288fn validate_intent_ids(intent: &PackInstallIntent) -> io::Result<()> {
289 validate_install_id(&intent.install_id)?;
290 pack_name_to_digest(&intent.pack_name)?;
291 Ok(())
292}
293
294pub(crate) fn staging_root(packs_dir: &Path) -> PathBuf {
295 packs_dir.join(STAGING_DIR_NAME)
296}
297
298pub(crate) fn intent_root(packs_dir: &Path) -> PathBuf {
299 packs_dir.join(INTENT_DIR_NAME)
300}
301
302fn quarantine_root(packs_dir: &Path) -> PathBuf {
303 intent_root(packs_dir).join(QUARANTINE_DIR_NAME)
304}
305
306pub(crate) fn intent_path(packs_dir: &Path, install_id: &str) -> PathBuf {
307 intent_root(packs_dir).join(format!("{install_id}.json"))
308}
309
310pub(crate) fn staging_dir(packs_dir: &Path, install_id: &str) -> PathBuf {
311 staging_root(packs_dir).join(install_id)
312}
313
314fn staging_pack_path(packs_dir: &Path, install_id: &str) -> PathBuf {
315 staging_dir(packs_dir, install_id).join(STAGED_PACK_NAME)
316}
317
318fn staging_idx_path(packs_dir: &Path, install_id: &str) -> PathBuf {
319 staging_dir(packs_dir, install_id).join(STAGED_IDX_NAME)
320}
321
322fn dst_pack_path(packs_dir: &Path, pack_name: &str) -> PathBuf {
323 packs_dir.join(format!("{pack_name}.pack"))
324}
325
326fn dst_idx_path(packs_dir: &Path, pack_name: &str) -> PathBuf {
327 packs_dir.join(format!("{pack_name}.idx"))
328}
329
330fn path_is_within(path: &Path, root: &Path) -> bool {
332 path == root || path.starts_with(root)
333}
334
335fn assert_under_packs(packs_dir: &Path, candidate: &Path) -> io::Result<()> {
345 if !packs_dir.exists() {
346 return Err(io::Error::new(
347 io::ErrorKind::NotFound,
348 "packs_dir does not exist for path containment check",
349 ));
350 }
351 let packs_canon = fs::canonicalize(packs_dir)?;
352
353 let rel = candidate.strip_prefix(packs_dir).or_else(|_| {
354 candidate
355 .strip_prefix(&packs_canon)
356 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path is not under packs_dir"))
357 })?;
358
359 for c in rel.components() {
360 match c {
361 Component::Normal(_) | Component::CurDir => {}
362 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
363 return Err(io::Error::new(
364 io::ErrorKind::InvalidInput,
365 "path escapes packs_dir",
366 ));
367 }
368 }
369 }
370
371 let mut cur = packs_canon.clone();
372 for c in rel.components() {
373 let Component::Normal(name) = c else {
374 continue;
375 };
376 cur.push(name);
377 match cur.symlink_metadata() {
379 Ok(_) => {
380 let canon = fs::canonicalize(&cur).map_err(|e| {
381 io::Error::new(
382 e.kind(),
383 format!(
384 "cannot resolve path under packs_dir ({}): {e}",
385 cur.display()
386 ),
387 )
388 })?;
389 if !path_is_within(&canon, &packs_canon) {
390 return Err(io::Error::new(
391 io::ErrorKind::InvalidInput,
392 "reconstructed path escapes packs_dir via symlink or mount",
393 ));
394 }
395 cur = canon;
397 }
398 Err(e) if e.kind() == io::ErrorKind::NotFound => {
399 break;
401 }
402 Err(e) => return Err(e),
403 }
404 }
405 Ok(())
406}
407
408fn ensure_journal_layout_safe(packs_dir: &Path) -> io::Result<()> {
411 create_dir_all_durable(packs_dir)?;
412 for name in [STAGING_DIR_NAME, INTENT_DIR_NAME, PACK_LOCKS_DIR_NAME] {
413 let p = packs_dir.join(name);
414 if p.symlink_metadata().is_ok() {
415 assert_under_packs(packs_dir, &p)?;
416 }
417 }
418 Ok(())
419}
420
421pub(crate) fn write_intent(packs_dir: &Path, intent: &PackInstallIntent) -> io::Result<()> {
426 validate_intent_ids(intent)?;
427 create_dir_all_durable(&intent_root(packs_dir))?;
428 let path = intent_path(packs_dir, &intent.install_id);
429 let bytes = serde_json::to_vec_pretty(intent)
430 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
431 write_file_atomic(&path, &bytes)
432}
433
434pub(crate) fn load_intent(path: &Path) -> io::Result<PackInstallIntent> {
435 let bytes = fs::read(path)?;
436 serde_json::from_slice(&bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
437}
438
439pub(crate) fn remove_intent(packs_dir: &Path, install_id: &str) -> io::Result<()> {
440 validate_install_id(install_id)?;
441 let path = intent_path(packs_dir, install_id);
442 match fs::remove_file(&path) {
443 Ok(()) => {}
444 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
445 Err(e) => return Err(e),
446 }
447 let _ = sync_directory(&intent_root(packs_dir));
449 Ok(())
450}
451
452fn quarantine_intent_file(packs_dir: &Path, path: &Path) -> io::Result<()> {
453 let qroot = quarantine_root(packs_dir);
454 create_dir_all_durable(&qroot)?;
455 let name = path
456 .file_name()
457 .and_then(|n| n.to_str())
458 .unwrap_or("intent.json");
459 let dest = qroot.join(format!("{name}.{}.bad", unix_now()));
460 match fs::rename(path, &dest) {
461 Ok(()) => {
462 let _ = sync_directory(&qroot);
463 tracing::warn!(
464 from = %path.display(),
465 to = %dest.display(),
466 "quarantined unreadable pack install intent"
467 );
468 Ok(())
469 }
470 Err(e) => {
471 Err(e)
473 }
474 }
475}
476
477fn remove_path_best_effort(path: &Path) {
478 if path.is_dir() {
479 let _ = fs::remove_dir_all(path);
480 } else {
481 let _ = fs::remove_file(path);
482 }
483}
484
485pub(crate) fn remove_staging(packs_dir: &Path, install_id: &str) {
486 if validate_install_id(install_id).is_err() {
487 return;
488 }
489 remove_path_best_effort(&staging_dir(packs_dir, install_id));
490}
491
492pub(crate) fn abort_install(packs_dir: &Path, intent: &PackInstallIntent) -> io::Result<()> {
497 validate_intent_ids(intent)?;
498 let dst_pack = dst_pack_path(packs_dir, &intent.pack_name);
499 let dst_idx = dst_idx_path(packs_dir, &intent.pack_name);
500 assert_under_packs(packs_dir, &dst_pack)?;
501 assert_under_packs(packs_dir, &dst_idx)?;
502
503 if dst_pack.exists() && !dst_idx.exists() {
505 let _ = fs::remove_file(&dst_pack);
506 if let Some(parent) = dst_pack.parent() {
507 let _ = sync_directory(parent);
508 }
509 }
510 remove_staging(packs_dir, &intent.install_id);
511 remove_intent(packs_dir, &intent.install_id)?;
512 Ok(())
513}
514
515pub(crate) fn complete_from_staging(
516 packs_dir: &Path,
517 intent: &PackInstallIntent,
518) -> io::Result<()> {
519 validate_intent_ids(intent)?;
520 let staging_idx = staging_idx_path(packs_dir, &intent.install_id);
521 let dst_idx = dst_idx_path(packs_dir, &intent.pack_name);
522 let dst_pack = dst_pack_path(packs_dir, &intent.pack_name);
523 assert_under_packs(packs_dir, &staging_idx)?;
524 assert_under_packs(packs_dir, &dst_idx)?;
525 assert_under_packs(packs_dir, &dst_pack)?;
526
527 if dst_pack.exists() && dst_idx.exists() {
528 remove_staging(packs_dir, &intent.install_id);
529 remove_intent(packs_dir, &intent.install_id)?;
530 return Ok(());
531 }
532
533 if !dst_pack.exists() || !staging_idx.exists() {
534 return abort_install(packs_dir, intent);
535 }
536
537 publish_file_durable(&staging_idx, &dst_idx)?;
538 remove_staging(packs_dir, &intent.install_id);
539 remove_intent(packs_dir, &intent.install_id)?;
540 Ok(())
541}
542
543fn can_complete_quickly(packs_dir: &Path, intent: &PackInstallIntent) -> bool {
544 let dst_pack = dst_pack_path(packs_dir, &intent.pack_name);
545 let dst_idx = dst_idx_path(packs_dir, &intent.pack_name);
546 let staging_idx = staging_idx_path(packs_dir, &intent.install_id);
547 if dst_pack.exists() && dst_idx.exists() {
548 return true;
549 }
550 dst_pack.exists() && !dst_idx.exists() && staging_idx.exists()
551}
552
553fn effective_created_unix(created_unix: i64, now: i64) -> i64 {
556 if created_unix > now {
557 now
558 } else {
559 created_unix
560 }
561}
562
563fn intent_expired(intent: &PackInstallIntent, ttl_secs: Option<i64>, now: i64) -> bool {
564 match ttl_secs {
565 Some(ttl) if ttl >= 0 => {
566 if intent.created_unix > now.saturating_add(INTENT_CLOCK_SKEW_TOLERANCE_SECS) {
570 return true;
571 }
572 let created = effective_created_unix(intent.created_unix, now);
573 created.saturating_add(ttl) < now
574 }
575 _ => false,
576 }
577}
578
579fn hash_file_blake3(path: &Path) -> io::Result<[u8; 32]> {
585 let mut file = File::open(path)?;
586 let mut hasher = blake3::Hasher::new();
587 let mut buf = vec![0u8; 64 * 1024];
588 loop {
589 let n = file.read(&mut buf)?;
590 if n == 0 {
591 break;
592 }
593 hasher.update(&buf[..n]);
594 }
595 Ok(*hasher.finalize().as_bytes())
596}
597
598#[cfg(test)]
611fn existing_pair_matches_pack_name(packs_dir: &Path, pack_name: &str) -> io::Result<bool> {
612 let expected = pack_name_to_digest(pack_name)?;
613 existing_pair_matches_digest(packs_dir, pack_name, &expected)
614}
615
616fn existing_pair_matches_digest(
619 packs_dir: &Path,
620 pack_name: &str,
621 expected: &[u8; 32],
622) -> io::Result<bool> {
623 let pack = dst_pack_path(packs_dir, pack_name);
624 let idx = dst_idx_path(packs_dir, pack_name);
625 assert_under_packs(packs_dir, &pack)?;
626 assert_under_packs(packs_dir, &idx)?;
627 if !pack.exists() || !idx.exists() {
628 return Ok(false);
629 }
630 if !pack.is_file() || !idx.is_file() {
633 return Ok(false);
634 }
635 if idx.metadata()?.len() == 0 {
636 return Ok(false);
637 }
638 let actual = hash_file_blake3(&pack)?;
639 if actual != *expected {
640 return Ok(false);
641 }
642 let idx_bytes = fs::read(&idx)?;
643 match crate::store::pack::PackIndex::from_bytes(&idx_bytes) {
644 Ok(_) => Ok(true),
645 Err(_) => Ok(false),
646 }
647}
648
649pub fn recover_pack_install_intents(packs_dir: &Path) -> io::Result<PackInstallRecoverReport> {
654 recover_pack_install_intents_with_ttl(packs_dir, Some(DEFAULT_PACK_INSTALL_INTENT_TTL_SECS))
655}
656
657pub fn recover_pack_install_intents_with_ttl(
658 packs_dir: &Path,
659 ttl_secs: Option<i64>,
660) -> io::Result<PackInstallRecoverReport> {
661 ensure_journal_layout_safe(packs_dir)?;
663
664 let intent_paths: Vec<PathBuf> = {
667 let _list_guard = acquire_pack_install_lock(packs_dir)?;
668 let intent_dir = intent_root(packs_dir);
669 if !intent_dir.exists() {
670 let mut report = PackInstallRecoverReport::default();
671 let now = unix_now();
672 sweep_orphan_staging(packs_dir, ttl_secs, now, &mut report);
673 return Ok(report);
674 }
675 fs::read_dir(&intent_dir)?
676 .filter_map(|e| e.ok())
677 .map(|e| e.path())
678 .filter(|p| !p.is_dir() && p.extension().and_then(|x| x.to_str()) == Some("json"))
679 .collect()
680 };
681
682 let mut report = PackInstallRecoverReport::default();
683 let now = unix_now();
684
685 for path in intent_paths {
686 report.intents_seen += 1;
687
688 let intent = match load_intent(&path) {
689 Ok(i)
690 if i.version == PACK_INSTALL_INTENT_VERSION
691 || i.version == 1 =>
692 {
693 match validate_intent_ids(&i) {
694 Ok(()) => i,
695 Err(_) => {
696 let _ = quarantine_intent_file(packs_dir, &path);
697 report.quarantined += 1;
698 metric_inc(&METRIC_RECOVER_QUARANTINED);
699 continue;
700 }
701 }
702 }
703 Ok(_) | Err(_) => {
704 match quarantine_intent_file(packs_dir, &path) {
705 Ok(()) => {
706 report.quarantined += 1;
707 metric_inc(&METRIC_RECOVER_QUARANTINED);
708 }
709 Err(_) => report.errors += 1,
710 }
711 continue;
712 }
713 };
714
715 let pack_guard = match try_acquire_pack_name_lock(packs_dir, &intent.pack_name)? {
717 Some(g) => g,
718 None => {
719 report.skipped_in_progress += 1;
720 metric_inc(&METRIC_RECOVER_SKIPPED);
721 continue;
722 }
723 };
724
725 let expired = intent_expired(&intent, ttl_secs, now);
726 let before_aborted = report.aborted;
727 let before_completed = report.completed;
728 let before_skipped = report.skipped_in_progress;
729 if recover_one_intent(packs_dir, &intent, expired, &mut report).is_err() {
730 report.errors += 1;
731 }
732 if report.completed > before_completed {
733 metric_inc(&METRIC_RECOVER_COMPLETED);
734 }
735 if report.aborted > before_aborted {
736 metric_inc(&METRIC_RECOVER_ABORTED);
737 }
738 if report.skipped_in_progress > before_skipped {
739 metric_inc(&METRIC_RECOVER_SKIPPED);
740 }
741 drop(pack_guard);
742 }
743
744 sweep_orphan_staging(packs_dir, ttl_secs, now, &mut report);
745
746 if report.intents_seen > 0
747 || report.orphan_staging_swept > 0
748 || report.errors > 0
749 || report.completed > 0
750 || report.aborted > 0
751 || report.skipped_in_progress > 0
752 || report.quarantined > 0
753 {
754 tracing::info!(
755 ?packs_dir,
756 intents_seen = report.intents_seen,
757 completed = report.completed,
758 aborted = report.aborted,
759 skipped_in_progress = report.skipped_in_progress,
760 cleaned_stale_completed = report.cleaned_stale_completed,
761 orphan_staging_swept = report.orphan_staging_swept,
762 quarantined = report.quarantined,
763 errors = report.errors,
764 metrics = ?pack_install_metrics_snapshot(),
765 "pack install journal recovery"
766 );
767 } else {
768 tracing::debug!(?packs_dir, "pack install journal recovery: nothing to do");
769 }
770
771 Ok(report)
772}
773
774fn recover_one_intent(
775 packs_dir: &Path,
776 intent: &PackInstallIntent,
777 expired: bool,
778 report: &mut PackInstallRecoverReport,
779) -> io::Result<()> {
780 if can_complete_quickly(packs_dir, intent) {
781 return match intent.phase {
782 PackInstallPhase::Prepared | PackInstallPhase::PackPublished => {
783 let dst_pack = dst_pack_path(packs_dir, &intent.pack_name);
784 let dst_idx = dst_idx_path(packs_dir, &intent.pack_name);
785 if dst_pack.exists() && dst_idx.exists() {
786 remove_staging(packs_dir, &intent.install_id);
787 remove_intent(packs_dir, &intent.install_id)?;
788 report.cleaned_stale_completed += 1;
789 Ok(())
790 } else {
791 complete_from_staging(packs_dir, intent)?;
792 if dst_pack_path(packs_dir, &intent.pack_name).exists()
793 && dst_idx_path(packs_dir, &intent.pack_name).exists()
794 {
795 report.completed += 1;
796 } else {
797 report.aborted += 1;
798 }
799 Ok(())
800 }
801 }
802 PackInstallPhase::Completed => {
803 remove_staging(packs_dir, &intent.install_id);
804 remove_intent(packs_dir, &intent.install_id)?;
805 report.cleaned_stale_completed += 1;
806 Ok(())
807 }
808 };
809 }
810
811 if expired {
812 tracing::debug!(
813 install_id = %intent.install_id,
814 pack_name = %intent.pack_name,
815 phase = ?intent.phase,
816 "aborting expired pack install intent"
817 );
818 abort_install(packs_dir, intent)?;
819 report.aborted += 1;
820 return Ok(());
821 }
822
823 match intent.phase {
824 PackInstallPhase::Prepared | PackInstallPhase::PackPublished => {
825 report.skipped_in_progress += 1;
826 Ok(())
827 }
828 PackInstallPhase::Completed => {
829 remove_staging(packs_dir, &intent.install_id);
830 remove_intent(packs_dir, &intent.install_id)?;
831 report.cleaned_stale_completed += 1;
832 Ok(())
833 }
834 }
835}
836
837fn sweep_orphan_staging(
838 packs_dir: &Path,
839 ttl_secs: Option<i64>,
840 now: i64,
841 report: &mut PackInstallRecoverReport,
842) {
843 let Some(ttl) = ttl_secs.filter(|t| *t >= 0) else {
844 return;
845 };
846 let staging = staging_root(packs_dir);
847 let entries = match fs::read_dir(&staging) {
848 Ok(e) => e,
849 Err(_) => return,
850 };
851 for entry in entries.flatten() {
852 let path = entry.path();
853 if !path.is_dir() {
854 continue;
855 }
856 let Some(id) = path.file_name().and_then(|n| n.to_str()) else {
857 continue;
858 };
859 if validate_install_id(id).is_err() {
860 continue;
861 }
862 if intent_path(packs_dir, id).exists() {
863 continue;
864 }
865 let age_ok_to_sweep = path_mtime_unix(&path)
866 .map(|mtime| mtime.saturating_add(ttl) < now)
867 .unwrap_or(true);
868 if !age_ok_to_sweep {
869 continue;
870 }
871 remove_path_best_effort(&path);
872 report.orphan_staging_swept += 1;
873 }
874}
875
876fn path_mtime_unix(path: &Path) -> Option<i64> {
877 let meta = fs::metadata(path).ok()?;
878 let modified = meta.modified().ok()?;
879 Some(
880 modified
881 .duration_since(UNIX_EPOCH)
882 .map(|d| d.as_secs() as i64)
883 .unwrap_or(0),
884 )
885}
886
887pub(crate) fn install_snapshot_pack_bytes(
901 packs_dir: &Path,
902 pack_data: Vec<u8>,
903 index_data: Vec<u8>,
904) -> io::Result<String> {
905 install_snapshot_pack_bytes_inner(packs_dir, pack_data, index_data, &[])
906}
907
908pub(crate) fn install_committed_snapshot_pack_bytes(
909 packs_dir: &Path,
910 pack_data: Vec<u8>,
911 index_data: Vec<u8>,
912 artifact_id: ContentHash,
913) -> io::Result<String> {
914 install_snapshot_pack_bytes_inner(packs_dir, pack_data, index_data, &[artifact_id])
915}
916
917pub(crate) fn install_snapshot_pack_bytes_with_commit_markers(
918 packs_dir: &Path,
919 pack_data: Vec<u8>,
920 index_data: Vec<u8>,
921 artifact_ids: &[ContentHash],
922) -> io::Result<String> {
923 install_snapshot_pack_bytes_inner(packs_dir, pack_data, index_data, artifact_ids)
924}
925
926fn install_snapshot_pack_bytes_inner(
927 packs_dir: &Path,
928 pack_data: Vec<u8>,
929 index_data: Vec<u8>,
930 artifact_ids: &[ContentHash],
931) -> io::Result<String> {
932 ensure_journal_layout_safe(packs_dir)?;
933 let digest = *blake3::hash(&pack_data).as_bytes();
934 let pack_name = digest_to_pack_name(&digest);
935 let _guard = acquire_pack_name_lock(packs_dir, &pack_name)?;
936
937 let pack_path = dst_pack_path(packs_dir, &pack_name);
938 if existing_pair_matches_digest(packs_dir, &pack_name, &digest)? {
939 for artifact_id in artifact_ids {
940 let marker = snapshot_commit_marker_path(&pack_path, artifact_id);
941 if !marker.exists() {
942 OpenOptions::new()
943 .write(true)
944 .create_new(true)
945 .open(marker)?;
946 }
947 }
948 sync_directory(packs_dir)?;
949 return Ok(pack_name);
950 }
951
952 let dst_pack = dst_pack_path(packs_dir, &pack_name);
953 let dst_idx = dst_idx_path(packs_dir, &pack_name);
954 assert_under_packs(packs_dir, &dst_pack)?;
955 assert_under_packs(packs_dir, &dst_idx)?;
956 if dst_pack.exists() {
957 fs::remove_file(&dst_pack)?;
958 }
959 if dst_idx.exists() {
960 fs::remove_file(&dst_idx)?;
961 }
962
963 let tmp_pack = temp_path(&dst_pack);
964 let tmp_idx = temp_path(&dst_idx);
965 let result = (|| {
966 stage_snapshot_pack_pair_durable(&tmp_pack, pack_data, &tmp_idx, index_data)?;
967 fs::rename(&tmp_pack, &dst_pack)?;
968 fault_inject::maybe_fail_at("snapshot_pack_after_publish_pack")?;
969 fs::rename(&tmp_idx, &dst_idx)?;
970 fault_inject::maybe_fail_at("snapshot_pack_after_publish_idx")?;
971 for artifact_id in artifact_ids {
972 let marker = snapshot_commit_marker_path(&dst_pack, artifact_id);
973 OpenOptions::new()
974 .write(true)
975 .create_new(true)
976 .open(marker)?;
977 }
978 sync_directory(packs_dir)?;
979 Ok(pack_name.clone())
980 })();
981 if result.is_err() {
982 let _ = fs::remove_file(&tmp_pack);
983 let _ = fs::remove_file(&tmp_idx);
984 }
985 result
986}
987
988fn stage_snapshot_pack_pair_durable(
989 pack_path: &Path,
990 pack_data: Vec<u8>,
991 index_path: &Path,
992 index_data: Vec<u8>,
993) -> io::Result<()> {
994 let mut pack = OpenOptions::new()
995 .write(true)
996 .create_new(true)
997 .open(pack_path)?;
998 pack.write_all(&pack_data)?;
999 let mut index = OpenOptions::new()
1000 .write(true)
1001 .create_new(true)
1002 .open(index_path)?;
1003 index.write_all(&index_data)?;
1004
1005 let (pack_sync, index_sync) = thread::scope(|scope| {
1006 let pack_sync = scope.spawn(move || sync_file(&pack, pack_path));
1007 let index_sync = scope.spawn(move || sync_file(&index, index_path));
1008 (pack_sync.join(), index_sync.join())
1009 });
1010 pack_sync.map_err(|_| io::Error::other("snapshot pack sync worker panicked"))??;
1011 index_sync.map_err(|_| io::Error::other("snapshot index sync worker panicked"))??;
1012 Ok(())
1013}
1014
1015fn new_install_id() -> String {
1016 let t = unix_now() as u64;
1017 let r: u64 = rand::random();
1018 format!("{t:016x}-{r:016x}")
1019}
1020
1021pub fn install_pack_bytes_journaled(
1023 packs_dir: &Path,
1024 pack_data: &[u8],
1025 index_data: &[u8],
1026) -> io::Result<String> {
1027 match install_pack_bytes_journaled_inner(packs_dir, pack_data, index_data) {
1028 Ok(name) => {
1029 metric_inc(&METRIC_INSTALLS_OK);
1030 Ok(name)
1031 }
1032 Err(e) => {
1033 metric_inc(&METRIC_INSTALLS_ERR);
1034 Err(e)
1035 }
1036 }
1037}
1038
1039fn install_pack_bytes_journaled_inner(
1040 packs_dir: &Path,
1041 pack_data: &[u8],
1042 index_data: &[u8],
1043) -> io::Result<String> {
1044 ensure_journal_layout_safe(packs_dir)?;
1045 let digest = *blake3::hash(pack_data).as_bytes();
1047 let pack_name = digest_to_pack_name(&digest);
1048
1049 if existing_pair_matches_digest(packs_dir, &pack_name, &digest)? {
1050 return Ok(pack_name);
1051 }
1052
1053 let install_id = new_install_id();
1055 validate_install_id(&install_id)?;
1056 let stage = staging_dir(packs_dir, &install_id);
1057 assert_under_packs(packs_dir, &stage)?;
1058 create_dir_all_durable(&stage)?;
1059 let staging_pack = staging_pack_path(packs_dir, &install_id);
1060 let staging_idx = staging_idx_path(packs_dir, &install_id);
1061 assert_under_packs(packs_dir, &staging_pack)?;
1062 assert_under_packs(packs_dir, &staging_idx)?;
1063 write_file_atomic(&staging_pack, pack_data)?;
1064 fault_inject::maybe_fail_at("pack_install_after_stage_pack")?;
1065 write_file_atomic(&staging_idx, index_data)?;
1066 fault_inject::maybe_fail_at("pack_install_after_stage_idx")?;
1067
1068 let _guard = acquire_pack_name_lock(packs_dir, &pack_name)?;
1070 fault_inject::maybe_fail_at("pack_install_after_pack_lock")?;
1071
1072 if existing_pair_matches_digest(packs_dir, &pack_name, &digest)? {
1073 remove_staging(packs_dir, &install_id);
1074 return Ok(pack_name);
1075 }
1076 let dst_pack = dst_pack_path(packs_dir, &pack_name);
1077 let dst_idx = dst_idx_path(packs_dir, &pack_name);
1078 assert_under_packs(packs_dir, &dst_pack)?;
1079 assert_under_packs(packs_dir, &dst_idx)?;
1080 if dst_pack.exists() && !dst_idx.exists() {
1081 let _ = fs::remove_file(&dst_pack);
1082 }
1083 if dst_pack.exists() && dst_idx.exists() {
1084 if !existing_pair_matches_digest(packs_dir, &pack_name, &digest)? {
1085 let _ = fs::remove_file(&dst_pack);
1086 let _ = fs::remove_file(&dst_idx);
1087 } else {
1088 remove_staging(packs_dir, &install_id);
1089 return Ok(pack_name);
1090 }
1091 }
1092
1093 let mut intent = PackInstallIntent::new(install_id.clone(), pack_name.clone());
1094 write_intent(packs_dir, &intent)?;
1095 fault_inject::maybe_fail_at("pack_install_after_intent_prepared")?;
1096
1097 publish_file_durable(&staging_pack, &dst_pack)?;
1098 fault_inject::maybe_fail_at("pack_install_after_publish_pack")?;
1099 intent.phase = PackInstallPhase::PackPublished;
1100 write_intent(packs_dir, &intent)?;
1101 fault_inject::maybe_fail_at("pack_install_after_intent_pack_published")?;
1102
1103 publish_file_durable(&staging_idx, &dst_idx)?;
1104 fault_inject::maybe_fail_at("pack_install_after_publish_idx")?;
1105 remove_staging(packs_dir, &install_id);
1106 remove_intent(packs_dir, &install_id)?;
1107 fault_inject::maybe_fail_at("pack_install_after_intent_removed")?;
1108 Ok(pack_name)
1109}
1110
1111pub fn install_pack_files_journaled(
1113 packs_dir: &Path,
1114 src_pack_path: &Path,
1115 src_index_path: &Path,
1116 pack_name: &str,
1117) -> io::Result<()> {
1118 match install_pack_files_journaled_inner(packs_dir, src_pack_path, src_index_path, pack_name) {
1119 Ok(()) => {
1120 metric_inc(&METRIC_INSTALLS_OK);
1121 Ok(())
1122 }
1123 Err(e) => {
1124 metric_inc(&METRIC_INSTALLS_ERR);
1125 Err(e)
1126 }
1127 }
1128}
1129
1130fn install_pack_files_journaled_inner(
1131 packs_dir: &Path,
1132 src_pack_path: &Path,
1133 src_index_path: &Path,
1134 pack_name: &str,
1135) -> io::Result<()> {
1136 let expected = pack_name_to_digest(pack_name)?;
1138 ensure_journal_layout_safe(packs_dir)?;
1139
1140 if existing_pair_matches_digest(packs_dir, pack_name, &expected)? {
1141 let _ = fs::remove_file(src_pack_path);
1142 let _ = fs::remove_file(src_index_path);
1143 return Ok(());
1144 }
1145
1146 let install_id = new_install_id();
1147 validate_install_id(&install_id)?;
1148 let stage = staging_dir(packs_dir, &install_id);
1149 assert_under_packs(packs_dir, &stage)?;
1150 create_dir_all_durable(&stage)?;
1151 let staging_pack = staging_pack_path(packs_dir, &install_id);
1152 let staging_idx = staging_idx_path(packs_dir, &install_id);
1153 assert_under_packs(packs_dir, &staging_pack)?;
1154 assert_under_packs(packs_dir, &staging_idx)?;
1155 publish_file_durable(src_pack_path, &staging_pack)?;
1156 fault_inject::maybe_fail_at("pack_install_stream_after_stage_pack")?;
1157 publish_file_durable(src_index_path, &staging_idx)?;
1158 fault_inject::maybe_fail_at("pack_install_stream_after_stage_idx")?;
1159
1160 let _guard = acquire_pack_name_lock(packs_dir, pack_name)?;
1161 if existing_pair_matches_digest(packs_dir, pack_name, &expected)? {
1162 remove_staging(packs_dir, &install_id);
1163 return Ok(());
1164 }
1165 let dst_pack = dst_pack_path(packs_dir, pack_name);
1166 let dst_idx = dst_idx_path(packs_dir, pack_name);
1167 assert_under_packs(packs_dir, &dst_pack)?;
1168 assert_under_packs(packs_dir, &dst_idx)?;
1169 if dst_pack.exists() && !dst_idx.exists() {
1170 let _ = fs::remove_file(&dst_pack);
1171 }
1172 if dst_pack.exists() && dst_idx.exists() {
1173 if existing_pair_matches_digest(packs_dir, pack_name, &expected)? {
1174 remove_staging(packs_dir, &install_id);
1175 return Ok(());
1176 }
1177 let _ = fs::remove_file(&dst_pack);
1178 let _ = fs::remove_file(&dst_idx);
1179 }
1180
1181 let mut intent = PackInstallIntent::new(install_id.clone(), pack_name.to_string());
1182 write_intent(packs_dir, &intent)?;
1183 fault_inject::maybe_fail_at("pack_install_stream_after_intent_prepared")?;
1184
1185 publish_file_durable(&staging_pack, &dst_pack)?;
1186 fault_inject::maybe_fail_at("pack_install_stream_after_publish_pack")?;
1187 intent.phase = PackInstallPhase::PackPublished;
1188 write_intent(packs_dir, &intent)?;
1189 fault_inject::maybe_fail_at("pack_install_stream_after_intent_pack_published")?;
1190
1191 publish_file_durable(&staging_idx, &dst_idx)?;
1192 fault_inject::maybe_fail_at("pack_install_stream_after_publish_idx")?;
1193 remove_staging(packs_dir, &install_id);
1194 remove_intent(packs_dir, &install_id)?;
1195 Ok(())
1196}
1197
1198#[cfg(test)]
1203mod tests {
1204 use super::*;
1205 use crate::fs_atomic::write_file_atomic;
1206
1207 fn write_src(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
1208 let p = dir.join(name);
1209 write_file_atomic(&p, bytes).unwrap();
1210 p
1211 }
1212
1213 fn pack_id(seed: &str) -> String {
1215 digest_to_pack_name(blake3::hash(seed.as_bytes()).as_bytes())
1216 }
1217
1218 fn empty_idx_bytes() -> Vec<u8> {
1220 crate::store::pack::PackIndex::new().to_bytes()
1221 }
1222
1223 #[test]
1224 fn validate_ids_reject_path_traversal() {
1225 assert!(validate_install_id("../evil").is_err());
1226 assert!(validate_install_id("a/b").is_err());
1227 assert!(validate_install_id("").is_err());
1228 assert!(validate_pack_name("../x").is_err());
1229 assert!(validate_pack_name("not-hex!").is_err());
1230 assert!(validate_pack_name("deadbeef").is_err()); assert!(validate_pack_name(&"A".repeat(64)).is_err()); assert!(validate_install_id("abc-123_OK").is_ok());
1233 assert!(validate_pack_name(&pack_id("ok")).is_ok());
1234 assert_eq!(pack_id("ok").len(), 64);
1235 }
1236
1237 #[test]
1238 fn pack_name_digest_roundtrip_and_native_equality() {
1239 let body = b"digest-native-eq";
1240 let digest = *blake3::hash(body).as_bytes();
1241 let name = digest_to_pack_name(&digest);
1242 let parsed = pack_name_to_digest(&name).unwrap();
1243 assert_eq!(parsed, digest);
1244 let root = tempfile::tempdir().unwrap();
1246 let packs = root.path().join("packs");
1247 create_dir_all_durable(&packs).unwrap();
1248 write_file_atomic(&dst_pack_path(&packs, &name), body).unwrap();
1249 write_file_atomic(&dst_idx_path(&packs, &name), &empty_idx_bytes()).unwrap();
1250 assert!(existing_pair_matches_digest(&packs, &name, &digest).unwrap());
1251 let mut wrong = digest;
1252 wrong[0] ^= 0xff;
1253 assert!(!existing_pair_matches_digest(&packs, &name, &wrong).unwrap());
1254 }
1255
1256 #[test]
1257 fn malicious_intent_paths_ignored_reconstructed_from_packs_dir() {
1258 let root = tempfile::tempdir().unwrap();
1261 let packs = root.path().join("packs");
1262 create_dir_all_durable(&packs).unwrap();
1263 let outside = root.path().join("outside.txt");
1264 fs::write(&outside, b"secret").unwrap();
1265
1266 let install_id = "malicious1";
1267 let pack_name = pack_id("malicious-pack");
1268 let intent_dir = intent_root(&packs);
1269 create_dir_all_durable(&intent_dir).unwrap();
1270 let forged = serde_json::json!({
1271 "version": 2,
1272 "install_id": install_id,
1273 "pack_name": pack_name,
1274 "phase": "prepared",
1275 "created_unix": 1,
1276 "staging_pack": outside.display().to_string(),
1277 "dst_pack": outside.display().to_string(),
1278 "dst_idx": outside.display().to_string(),
1279 });
1280 fs::write(
1281 intent_path(&packs, install_id),
1282 serde_json::to_vec_pretty(&forged).unwrap(),
1283 )
1284 .unwrap();
1285
1286 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1288 assert_eq!(report.aborted, 1);
1289 assert_eq!(fs::read(&outside).unwrap(), b"secret");
1291 }
1292
1293 #[test]
1294 fn quarantine_unknown_version_preserves_file() {
1295 let root = tempfile::tempdir().unwrap();
1296 let packs = root.path().join("packs");
1297 create_dir_all_durable(&packs).unwrap();
1298 let intent_dir = intent_root(&packs);
1299 create_dir_all_durable(&intent_dir).unwrap();
1300 let path = intent_dir.join("weird.json");
1301 let pn = pack_id("weird");
1302 fs::write(
1303 &path,
1304 format!(
1305 r#"{{"version":99,"install_id":"x","pack_name":"{pn}","phase":"prepared","created_unix":1}}"#
1306 ),
1307 )
1308 .unwrap();
1309
1310 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1311 assert_eq!(report.quarantined, 1);
1312 assert!(!path.exists());
1313 let q = quarantine_root(&packs);
1314 assert!(q.exists());
1315 assert!(fs::read_dir(&q).unwrap().count() >= 1);
1316 }
1317
1318 #[test]
1319 fn short_pack_name_in_intent_is_quarantined() {
1320 let root = tempfile::tempdir().unwrap();
1321 let packs = root.path().join("packs");
1322 create_dir_all_durable(&packs).unwrap();
1323 create_dir_all_durable(&intent_root(&packs)).unwrap();
1324 let path = intent_path(&packs, "shortname");
1325 fs::write(
1326 &path,
1327 br#"{"version":2,"install_id":"shortname","pack_name":"aa","phase":"prepared","created_unix":1}"#,
1328 )
1329 .unwrap();
1330 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1331 assert_eq!(report.quarantined, 1);
1332 assert!(!path.exists());
1333 }
1334
1335 #[test]
1336 fn quarantine_malformed_json() {
1337 let root = tempfile::tempdir().unwrap();
1338 let packs = root.path().join("packs");
1339 create_dir_all_durable(&packs).unwrap();
1340 let intent_dir = intent_root(&packs);
1341 create_dir_all_durable(&intent_dir).unwrap();
1342 let path = intent_dir.join("bad.json");
1343 fs::write(&path, b"not-json{{{{").unwrap();
1344
1345 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1346 assert_eq!(report.quarantined, 1);
1347 assert!(!path.exists());
1348 }
1349
1350 #[test]
1351 fn existing_pair_requires_hash_match_and_valid_index() {
1352 let root = tempfile::tempdir().unwrap();
1353 let packs = root.path().join("packs");
1354 create_dir_all_durable(&packs).unwrap();
1355 let body = b"pack-body-xyz";
1356 let name = format!("{}", blake3::hash(body).to_hex());
1357 write_file_atomic(&dst_pack_path(&packs, &name), body).unwrap();
1358 write_file_atomic(&dst_idx_path(&packs, &name), &empty_idx_bytes()).unwrap();
1359 assert!(existing_pair_matches_pack_name(&packs, &name).unwrap());
1360
1361 write_file_atomic(&dst_idx_path(&packs, &name), b"not-an-index").unwrap();
1363 assert!(!existing_pair_matches_pack_name(&packs, &name).unwrap());
1364
1365 let wrong = pack_id("wrong-name-for-body");
1367 write_file_atomic(&dst_pack_path(&packs, &wrong), body).unwrap();
1368 write_file_atomic(&dst_idx_path(&packs, &wrong), &empty_idx_bytes()).unwrap();
1369 assert!(!existing_pair_matches_pack_name(&packs, &wrong).unwrap());
1370 }
1371
1372 #[test]
1373 fn install_rejects_idempotent_false_pair() {
1374 let root = tempfile::tempdir().unwrap();
1375 let packs = root.path().join("packs");
1376 create_dir_all_durable(&packs).unwrap();
1377 let pack_bytes = b"real-pack-content-111";
1378 let idx_bytes = empty_idx_bytes();
1379 let name = format!("{}", blake3::hash(pack_bytes).to_hex());
1380
1381 write_file_atomic(&dst_pack_path(&packs, &name), b"wrong-content").unwrap();
1383 write_file_atomic(&dst_idx_path(&packs, &name), b"x").unwrap();
1384 assert!(!existing_pair_matches_pack_name(&packs, &name).unwrap());
1385
1386 let out = install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes).unwrap();
1387 assert_eq!(out, name);
1388 assert_eq!(fs::read(dst_pack_path(&packs, &name)).unwrap(), pack_bytes);
1389 }
1390
1391 #[test]
1392 fn journaled_install_produces_pair_and_cleans_intent() {
1393 let root = tempfile::tempdir().unwrap();
1394 let packs = root.path().join("packs");
1395 create_dir_all_durable(&packs).unwrap();
1396 let src_dir = root.path().join("src");
1397 create_dir_all_durable(&src_dir).unwrap();
1398
1399 let pack_bytes = b"fake-pack-bytes-aaa";
1400 let idx_bytes = b"fake-idx-bytes-aaa";
1401 let src_pack = write_src(&src_dir, "p.pack", pack_bytes);
1402 let src_idx = write_src(&src_dir, "p.idx", idx_bytes);
1403 let name = format!("{}", blake3::hash(pack_bytes).to_hex());
1404
1405 install_pack_files_journaled(&packs, &src_pack, &src_idx, &name).unwrap();
1406
1407 assert!(dst_pack_path(&packs, &name).exists());
1408 assert!(dst_idx_path(&packs, &name).exists());
1409 assert_eq!(fs::read(dst_pack_path(&packs, &name)).unwrap(), pack_bytes);
1410 assert!(
1411 !intent_root(&packs).exists()
1412 || fs::read_dir(intent_root(&packs))
1413 .unwrap()
1414 .filter(|e| {
1415 e.as_ref()
1416 .map(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json"))
1417 .unwrap_or(false)
1418 })
1419 .count()
1420 == 0
1421 );
1422 }
1423
1424 #[test]
1425 fn recover_pack_published_completes_from_staging() {
1426 let root = tempfile::tempdir().unwrap();
1427 let packs = root.path().join("packs");
1428 create_dir_all_durable(&packs).unwrap();
1429
1430 let name = pack_id("deadbeef-seed");
1431 let install_id = "test-install-1";
1432 validate_install_id(install_id).unwrap();
1433 validate_pack_name(&name).unwrap();
1434 let stage = staging_dir(&packs, install_id);
1435 create_dir_all_durable(&stage).unwrap();
1436 write_file_atomic(&staging_pack_path(&packs, install_id), b"pack-body").unwrap();
1437 write_file_atomic(&staging_idx_path(&packs, install_id), b"idx-body").unwrap();
1438
1439 let dst_pack = dst_pack_path(&packs, &name);
1440 publish_file_durable(&staging_pack_path(&packs, install_id), &dst_pack).unwrap();
1441
1442 let intent = PackInstallIntent {
1443 version: PACK_INSTALL_INTENT_VERSION,
1444 install_id: install_id.to_string(),
1445 pack_name: name.clone(),
1446 phase: PackInstallPhase::PackPublished,
1447 created_unix: 1,
1448 };
1449 write_intent(&packs, &intent).unwrap();
1450
1451 let report = recover_pack_install_intents(&packs).unwrap();
1452 assert_eq!(report.intents_seen, 1);
1453 assert_eq!(report.completed, 1);
1454 assert!(dst_pack.exists());
1455 assert!(dst_idx_path(&packs, &name).exists());
1456 assert_eq!(fs::read(dst_idx_path(&packs, &name)).unwrap(), b"idx-body");
1457 assert!(!intent_path(&packs, install_id).exists());
1458 }
1459
1460 #[test]
1461 fn recover_prepared_aborts_without_finals_when_expired() {
1462 let root = tempfile::tempdir().unwrap();
1463 let packs = root.path().join("packs");
1464 create_dir_all_durable(&packs).unwrap();
1465 let install_id = "prep-abort";
1466 let stage = staging_dir(&packs, install_id);
1467 create_dir_all_durable(&stage).unwrap();
1468 write_file_atomic(&staging_pack_path(&packs, install_id), b"p").unwrap();
1469 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
1470
1471 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("aa"));
1472 intent.created_unix = 1;
1473 write_intent(&packs, &intent).unwrap();
1474
1475 let report = recover_pack_install_intents_with_ttl(&packs, Some(60)).unwrap();
1476 assert_eq!(report.aborted, 1);
1477 assert!(!dst_pack_path(&packs, &pack_id("aa")).exists());
1478 assert!(!intent_path(&packs, install_id).exists());
1479 assert!(!stage.exists());
1480 }
1481
1482 #[test]
1483 fn recover_prepared_fresh_skips_in_progress() {
1484 let root = tempfile::tempdir().unwrap();
1485 let packs = root.path().join("packs");
1486 create_dir_all_durable(&packs).unwrap();
1487 let install_id = "live-prep";
1488 let stage = staging_dir(&packs, install_id);
1489 create_dir_all_durable(&stage).unwrap();
1490 let staging_pack = staging_pack_path(&packs, install_id);
1491 let staging_idx = staging_idx_path(&packs, install_id);
1492 write_file_atomic(&staging_pack, b"live-p").unwrap();
1493 write_file_atomic(&staging_idx, b"live-i").unwrap();
1494
1495 let intent = PackInstallIntent::new(install_id.into(), pack_id("bb"));
1496 write_intent(&packs, &intent).unwrap();
1497
1498 let report = recover_pack_install_intents_with_ttl(&packs, Some(86_400)).unwrap();
1499 assert_eq!(report.skipped_in_progress, 1);
1500 assert_eq!(report.aborted, 0);
1501 assert!(staging_pack.exists());
1502 assert!(intent_path(&packs, install_id).exists());
1503 }
1504
1505 #[test]
1506 fn recover_pack_published_without_staging_idx_aborts_orphan_pack() {
1507 let root = tempfile::tempdir().unwrap();
1508 let packs = root.path().join("packs");
1509 create_dir_all_durable(&packs).unwrap();
1510 let name = pack_id("cc");
1511 let install_id = "orph-1";
1512 let dst_pack = dst_pack_path(&packs, &name);
1513 write_file_atomic(&dst_pack, b"only-pack").unwrap();
1514
1515 let intent = PackInstallIntent {
1516 version: PACK_INSTALL_INTENT_VERSION,
1517 install_id: install_id.into(),
1518 pack_name: name,
1519 phase: PackInstallPhase::PackPublished,
1520 created_unix: 1,
1521 };
1522 write_intent(&packs, &intent).unwrap();
1523
1524 let report = recover_pack_install_intents(&packs).unwrap();
1525 assert_eq!(report.aborted, 1);
1526 assert!(!dst_pack.exists());
1527 assert!(!intent_path(&packs, install_id).exists());
1528 }
1529
1530 #[test]
1531 fn pack_lock_prevents_recover_aborting_live_expired_looking_install() {
1532 use std::{
1533 sync::{Arc, Barrier},
1534 thread,
1535 time::Duration,
1536 };
1537
1538 let root = tempfile::tempdir().unwrap();
1541 let packs = Arc::new(root.path().join("packs"));
1542 create_dir_all_durable(&packs).unwrap();
1543
1544 let planted_under_lock = Arc::new(Barrier::new(2));
1545 let both_done = Arc::new(Barrier::new(2));
1546
1547 let packs_a = Arc::clone(&packs);
1548 let planted_a = Arc::clone(&planted_under_lock);
1549 let done_a = Arc::clone(&both_done);
1550
1551 let installer = thread::spawn(move || {
1552 let packs = packs_a.as_path();
1553 let guard = acquire_pack_name_lock(packs, &pack_id("dd")).expect("pack lock");
1554
1555 let install_id = "flock-live";
1556 let stage = staging_dir(packs, install_id);
1557 create_dir_all_durable(&stage).unwrap();
1558 let staging_pack = staging_pack_path(packs, install_id);
1559 let staging_idx = staging_idx_path(packs, install_id);
1560 write_file_atomic(&staging_pack, b"flock-pack").unwrap();
1561 write_file_atomic(&staging_idx, b"flock-idx").unwrap();
1562 let dst_pack = dst_pack_path(packs, &pack_id("dd"));
1563 let dst_idx = dst_idx_path(packs, &pack_id("dd"));
1564
1565 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("dd"));
1566 intent.created_unix = 1; write_intent(packs, &intent).unwrap();
1568
1569 planted_a.wait();
1570 thread::sleep(Duration::from_millis(60));
1571
1572 assert!(staging_pack.exists() && staging_idx.exists());
1573 assert!(intent_path(packs, install_id).exists());
1574
1575 publish_file_durable(&staging_pack, &dst_pack).unwrap();
1576 intent.phase = PackInstallPhase::PackPublished;
1577 write_intent(packs, &intent).unwrap();
1578 publish_file_durable(&staging_idx, &dst_idx).unwrap();
1579 remove_staging(packs, install_id);
1580 remove_intent(packs, install_id).unwrap();
1581
1582 drop(guard);
1583 assert!(dst_pack.exists() && dst_idx.exists());
1584 done_a.wait();
1585 });
1586
1587 let packs_b = Arc::clone(&packs);
1588 let planted_b = Arc::clone(&planted_under_lock);
1589 let done_b = Arc::clone(&both_done);
1590
1591 let recoverer = thread::spawn(move || {
1592 let packs = packs_b.as_path();
1593 planted_b.wait();
1594 let mid = recover_pack_install_intents_with_ttl(packs, Some(1))
1596 .expect("recover under pack lock");
1597 assert_eq!(mid.aborted, 0, "must not abort live install: {mid:?}");
1598 assert!(
1599 mid.skipped_in_progress >= 1 || dst_pack_path(packs, &pack_id("dd")).exists(),
1600 "either skip in-progress or install already finished: {mid:?}"
1601 );
1602 done_b.wait();
1603 assert!(dst_pack_path(packs, &pack_id("dd")).exists());
1605 assert!(dst_idx_path(packs, &pack_id("dd")).exists());
1606 assert!(!intent_path(packs, "flock-live").exists());
1607 });
1608
1609 installer.join().expect("installer");
1610 recoverer.join().expect("recoverer");
1611 }
1612
1613 #[test]
1614 fn recover_prepared_with_pack_and_staging_idx_completes() {
1615 let root = tempfile::tempdir().unwrap();
1616 let packs = root.path().join("packs");
1617 create_dir_all_durable(&packs).unwrap();
1618 let install_id = "prep-complete";
1619 let stage = staging_dir(&packs, install_id);
1620 create_dir_all_durable(&stage).unwrap();
1621 write_file_atomic(&staging_pack_path(&packs, install_id), b"pack-x").unwrap();
1622 write_file_atomic(&staging_idx_path(&packs, install_id), b"idx-x").unwrap();
1623 let dst_pack = dst_pack_path(&packs, &pack_id("ee"));
1624 publish_file_durable(&staging_pack_path(&packs, install_id), &dst_pack).unwrap();
1625
1626 let intent = PackInstallIntent::new(install_id.into(), pack_id("ee"));
1627 write_intent(&packs, &intent).unwrap();
1628
1629 let report = recover_pack_install_intents(&packs).unwrap();
1630 assert_eq!(report.completed, 1);
1631 assert!(dst_pack.exists());
1632 assert!(dst_idx_path(&packs, &pack_id("ee")).exists());
1633 assert_eq!(
1634 fs::read(dst_idx_path(&packs, &pack_id("ee"))).unwrap(),
1635 b"idx-x"
1636 );
1637 }
1638
1639 #[test]
1640 fn journaled_install_idempotent_when_pair_exists() {
1641 let root = tempfile::tempdir().unwrap();
1642 let packs = root.path().join("packs");
1643 create_dir_all_durable(&packs).unwrap();
1644 let pack_bytes = b"idemp-pack-bytes";
1645 let name = format!("{}", blake3::hash(pack_bytes).to_hex());
1646 write_file_atomic(&dst_pack_path(&packs, &name), pack_bytes).unwrap();
1647 write_file_atomic(&dst_idx_path(&packs, &name), &empty_idx_bytes()).unwrap();
1648 assert!(existing_pair_matches_pack_name(&packs, &name).unwrap());
1649
1650 let src_dir = root.path().join("src");
1651 create_dir_all_durable(&src_dir).unwrap();
1652 let src_pack = write_src(&src_dir, "a", b"other");
1653 let src_idx = write_src(&src_dir, "b", b"other-i");
1654
1655 install_pack_files_journaled(&packs, &src_pack, &src_idx, &name).unwrap();
1656 assert_eq!(fs::read(dst_pack_path(&packs, &name)).unwrap(), pack_bytes);
1657 }
1658
1659 #[test]
1660 fn install_pack_bytes_journaled_happy_path() {
1661 let root = tempfile::tempdir().unwrap();
1662 let packs = root.path().join("packs");
1663 create_dir_all_durable(&packs).unwrap();
1664
1665 let pack_bytes = b"in-memory-pack-body-zzz";
1666 let idx_bytes = empty_idx_bytes();
1667 let expected_name = format!("{}", blake3::hash(pack_bytes).to_hex());
1668
1669 let name = install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes).unwrap();
1670 assert_eq!(name, expected_name);
1671 assert!(existing_pair_matches_pack_name(&packs, &name).unwrap());
1672
1673 let name2 = install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes).unwrap();
1674 assert_eq!(name2, expected_name);
1675 }
1676
1677 #[test]
1678 fn snapshot_pack_install_publishes_pair_without_an_intent() {
1679 let root = tempfile::tempdir().unwrap();
1680 let packs = root.path().join("packs");
1681 let pack_bytes = b"snapshot-pack-body".to_vec();
1682 let idx_bytes = empty_idx_bytes();
1683 let expected_name = format!("{}", blake3::hash(&pack_bytes).to_hex());
1684
1685 let name = install_snapshot_pack_bytes(&packs, pack_bytes, idx_bytes).unwrap();
1686 assert_eq!(name, expected_name);
1687 assert!(existing_pair_matches_pack_name(&packs, &name).unwrap());
1688 assert_eq!(intent_count_json(&packs), 0);
1689 }
1690
1691 #[test]
1692 fn snapshot_pack_install_repairs_an_interrupted_pair_before_commit() {
1693 let root = tempfile::tempdir().unwrap();
1694 let packs = root.path().join("packs");
1695 let pack_bytes = b"interrupted-snapshot-pack".to_vec();
1696 let idx_bytes = empty_idx_bytes();
1697 let expected_name = format!("{}", blake3::hash(&pack_bytes).to_hex());
1698
1699 let err = fault_inject::with_fault_points(&["snapshot_pack_after_publish_pack"], || {
1700 install_snapshot_pack_bytes(&packs, pack_bytes.clone(), idx_bytes.clone())
1701 })
1702 .expect_err("fault should stop publication before the index rename");
1703 assert!(err.to_string().contains("snapshot_pack_after_publish_pack"));
1704 assert!(dst_pack_path(&packs, &expected_name).exists());
1705 assert!(!dst_idx_path(&packs, &expected_name).exists());
1706
1707 let repaired = install_snapshot_pack_bytes(&packs, pack_bytes, idx_bytes).unwrap();
1708 assert_eq!(repaired, expected_name);
1709 assert!(existing_pair_matches_pack_name(&packs, &repaired).unwrap());
1710 }
1711
1712 #[test]
1713 fn committed_snapshot_marker_appears_only_after_the_complete_pair() {
1714 let root = tempfile::tempdir().unwrap();
1715 let packs = root.path().join("packs");
1716 let pack_bytes = b"authoritative-snapshot-pack".to_vec();
1717 let idx_bytes = empty_idx_bytes();
1718 let expected_name = format!("{}", blake3::hash(&pack_bytes).to_hex());
1719 let artifact_id = ContentHash::compute(b"snapshot artifact");
1720 let marker =
1721 snapshot_commit_marker_path(&dst_pack_path(&packs, &expected_name), &artifact_id);
1722
1723 let err = fault_inject::with_fault_points(&["snapshot_pack_after_publish_idx"], || {
1724 install_committed_snapshot_pack_bytes(
1725 &packs,
1726 pack_bytes.clone(),
1727 idx_bytes.clone(),
1728 artifact_id,
1729 )
1730 })
1731 .expect_err("fault should stop publication before the commit marker");
1732 assert!(err.to_string().contains("snapshot_pack_after_publish_idx"));
1733 assert!(existing_pair_matches_pack_name(&packs, &expected_name).unwrap());
1734 assert!(!marker.exists());
1735
1736 let repaired =
1737 install_committed_snapshot_pack_bytes(&packs, pack_bytes, idx_bytes, artifact_id)
1738 .unwrap();
1739 assert_eq!(repaired, expected_name);
1740 assert!(marker.exists());
1741 }
1742
1743 #[test]
1744 fn ttl_aborts_old_prepared_intent() {
1745 let root = tempfile::tempdir().unwrap();
1746 let packs = root.path().join("packs");
1747 create_dir_all_durable(&packs).unwrap();
1748 let install_id = "ttl-prep";
1749 let stage = staging_dir(&packs, install_id);
1750 create_dir_all_durable(&stage).unwrap();
1751 write_file_atomic(&staging_pack_path(&packs, install_id), b"stale-p").unwrap();
1752 write_file_atomic(&staging_idx_path(&packs, install_id), b"stale-i").unwrap();
1753
1754 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("ff"));
1755 intent.created_unix = 1;
1756 write_intent(&packs, &intent).unwrap();
1757
1758 let report = recover_pack_install_intents_with_ttl(&packs, Some(60)).unwrap();
1759 assert_eq!(report.aborted, 1);
1760 assert!(!intent_path(&packs, install_id).exists());
1761 assert!(!stage.exists());
1762 }
1763
1764 #[test]
1765 fn complete_preferred_over_ttl_when_staging_idx_present() {
1766 let root = tempfile::tempdir().unwrap();
1767 let packs = root.path().join("packs");
1768 create_dir_all_durable(&packs).unwrap();
1769
1770 let name = pack_id("11");
1771 let install_id = "ttl-complete-1";
1772 let stage = staging_dir(&packs, install_id);
1773 create_dir_all_durable(&stage).unwrap();
1774 write_file_atomic(&staging_pack_path(&packs, install_id), b"pack-ttl").unwrap();
1775 write_file_atomic(&staging_idx_path(&packs, install_id), b"idx-ttl").unwrap();
1776
1777 let dst_pack = dst_pack_path(&packs, &name);
1778 publish_file_durable(&staging_pack_path(&packs, install_id), &dst_pack).unwrap();
1779
1780 let intent = PackInstallIntent {
1781 version: PACK_INSTALL_INTENT_VERSION,
1782 install_id: install_id.into(),
1783 pack_name: name.clone(),
1784 phase: PackInstallPhase::PackPublished,
1785 created_unix: 1,
1786 };
1787 write_intent(&packs, &intent).unwrap();
1788
1789 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1790 assert_eq!(report.completed, 1);
1791 assert!(dst_idx_path(&packs, &name).exists());
1792 }
1793
1794 #[test]
1795 fn relocated_repo_recovery_uses_new_packs_dir() {
1796 let root = tempfile::tempdir().unwrap();
1798 let packs = root.path().join("old").join("packs");
1799 create_dir_all_durable(&packs).unwrap();
1800 let install_id = "reloc1";
1801 let name = pack_id("22");
1802 create_dir_all_durable(&staging_dir(&packs, install_id)).unwrap();
1803 write_file_atomic(&staging_pack_path(&packs, install_id), b"p").unwrap();
1804 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
1805 publish_file_durable(
1806 &staging_pack_path(&packs, install_id),
1807 &dst_pack_path(&packs, &name),
1808 )
1809 .unwrap();
1810 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
1812 let intent = PackInstallIntent {
1813 version: 2,
1814 install_id: install_id.into(),
1815 pack_name: name.clone(),
1816 phase: PackInstallPhase::PackPublished,
1817 created_unix: 1,
1818 };
1819 write_intent(&packs, &intent).unwrap();
1820
1821 let new_packs = root.path().join("new").join("packs");
1823 create_dir_all_durable(new_packs.parent().unwrap()).unwrap();
1824 fs::rename(&packs, &new_packs).unwrap();
1825
1826 let report = recover_pack_install_intents(&new_packs).unwrap();
1827 assert_eq!(report.completed, 1);
1828 assert!(dst_idx_path(&new_packs, &name).exists());
1829 }
1830
1831 #[test]
1832 fn concurrent_same_pack_installs_converge() {
1833 use std::thread;
1834
1835 let root = tempfile::tempdir().unwrap();
1836 let packs = root.path().join("packs");
1837 create_dir_all_durable(&packs).unwrap();
1838 let pack_bytes = b"same-pack-concurrent-body";
1839 let idx_bytes = empty_idx_bytes();
1840 let expected = format!("{}", blake3::hash(pack_bytes).to_hex());
1841
1842 let packs1 = packs.clone();
1843 let packs2 = packs.clone();
1844 let idx1 = idx_bytes.clone();
1845 let idx2 = idx_bytes.clone();
1846 let t1 = thread::spawn(move || {
1847 install_pack_bytes_journaled(&packs1, pack_bytes, &idx1).unwrap()
1848 });
1849 let t2 = thread::spawn(move || {
1850 install_pack_bytes_journaled(&packs2, pack_bytes, &idx2).unwrap()
1851 });
1852 let n1 = t1.join().unwrap();
1853 let n2 = t2.join().unwrap();
1854 assert_eq!(n1, expected);
1855 assert_eq!(n2, expected);
1856 assert!(existing_pair_matches_pack_name(&packs, &expected).unwrap());
1857 }
1858
1859 #[test]
1860 fn concurrent_many_distinct_pack_installs() {
1861 use std::thread;
1862
1863 let root = tempfile::tempdir().unwrap();
1865 let packs = root.path().join("packs");
1866 create_dir_all_durable(&packs).unwrap();
1867 let idx_bytes = empty_idx_bytes();
1868
1869 let mut handles = Vec::new();
1870 for i in 0..8u8 {
1871 let packs = packs.clone();
1872 let idx_bytes = idx_bytes.clone();
1873 handles.push(thread::spawn(move || {
1874 let pack_bytes = format!("many-pack-body-{i}").into_bytes();
1875 install_pack_bytes_journaled(&packs, &pack_bytes, &idx_bytes).unwrap()
1876 }));
1877 }
1878 let mut names = Vec::new();
1879 for h in handles {
1880 names.push(h.join().unwrap());
1881 }
1882 names.sort();
1883 names.dedup();
1884 assert_eq!(names.len(), 8, "expected 8 distinct pack names");
1885 for name in &names {
1886 assert!(existing_pair_matches_pack_name(&packs, name).unwrap());
1887 }
1888 }
1889
1890 #[test]
1891 fn far_future_created_unix_expires_immediately_under_ttl() {
1892 let root = tempfile::tempdir().unwrap();
1893 let packs = root.path().join("packs");
1894 create_dir_all_durable(&packs).unwrap();
1895 let install_id = "far-future";
1896 create_dir_all_durable(&staging_dir(&packs, install_id)).unwrap();
1897 write_file_atomic(&staging_pack_path(&packs, install_id), b"p").unwrap();
1898 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
1899
1900 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("aa"));
1901 intent.created_unix = unix_now().saturating_add(INTENT_CLOCK_SKEW_TOLERANCE_SECS + 10_000);
1903 write_intent(&packs, &intent).unwrap();
1904
1905 let report = recover_pack_install_intents_with_ttl(&packs, Some(86_400)).unwrap();
1906 assert_eq!(report.aborted, 1, "far-future must expire: {report:?}");
1907 assert!(!intent_path(&packs, install_id).exists());
1908 }
1909
1910 #[test]
1911 fn mild_clock_skew_does_not_expire_fresh_intent() {
1912 let root = tempfile::tempdir().unwrap();
1913 let packs = root.path().join("packs");
1914 create_dir_all_durable(&packs).unwrap();
1915 let install_id = "mild-skew";
1916 create_dir_all_durable(&staging_dir(&packs, install_id)).unwrap();
1917 write_file_atomic(&staging_pack_path(&packs, install_id), b"p").unwrap();
1918 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
1919
1920 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("bb"));
1921 intent.created_unix = unix_now().saturating_add(INTENT_CLOCK_SKEW_TOLERANCE_SECS / 2);
1923 write_intent(&packs, &intent).unwrap();
1924
1925 let report = recover_pack_install_intents_with_ttl(&packs, Some(86_400)).unwrap();
1926 assert_eq!(report.skipped_in_progress, 1, "mild skew: {report:?}");
1927 assert_eq!(report.aborted, 0);
1928 assert!(intent_path(&packs, install_id).exists());
1929 }
1930
1931 #[test]
1932 fn fault_after_intent_prepared_is_recoverable() {
1933 let root = tempfile::tempdir().unwrap();
1934 let packs = root.path().join("packs");
1935 create_dir_all_durable(&packs).unwrap();
1936
1937 let pack_bytes = b"fault-inject-pack-body";
1938 let idx_bytes = empty_idx_bytes();
1939 let expected = format!("{}", blake3::hash(pack_bytes).to_hex());
1940
1941 let before_err = pack_install_metrics_snapshot().installs_err;
1942 let err = fault_inject::with_fault_points(&["pack_install_after_intent_prepared"], || {
1943 install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes)
1944 })
1945 .expect_err("fault should fire");
1946 assert!(
1947 err.to_string()
1948 .contains("pack_install_after_intent_prepared"),
1949 "err={err}"
1950 );
1951 assert!(pack_install_metrics_snapshot().installs_err >= before_err);
1953
1954 assert_eq!(intent_count_json(&packs), 1);
1956 assert!(!dst_pack_path(&packs, &expected).exists());
1957
1958 for entry in fs::read_dir(intent_root(&packs)).unwrap().flatten() {
1960 let p = entry.path();
1961 if p.extension().and_then(|x| x.to_str()) != Some("json") {
1962 continue;
1963 }
1964 let mut intent: PackInstallIntent =
1965 serde_json::from_slice(&fs::read(&p).unwrap()).unwrap();
1966 intent.created_unix = 1;
1967 write_intent(&packs, &intent).unwrap();
1968 }
1969 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1970 assert_eq!(report.aborted, 1, "report={report:?}");
1971 assert_eq!(intent_count_json(&packs), 0);
1972
1973 let name = install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes).unwrap();
1974 assert_eq!(name, expected);
1975 assert!(existing_pair_matches_pack_name(&packs, &expected).unwrap());
1976 }
1977
1978 #[test]
1979 fn fault_after_publish_pack_recovers_to_complete() {
1980 let root = tempfile::tempdir().unwrap();
1981 let packs = root.path().join("packs");
1982 create_dir_all_durable(&packs).unwrap();
1983
1984 let pack_bytes = b"fault-after-pack-publish-body";
1985 let idx_bytes = empty_idx_bytes();
1986 let expected = format!("{}", blake3::hash(pack_bytes).to_hex());
1987
1988 let err = fault_inject::with_fault_points(&["pack_install_after_publish_pack"], || {
1989 install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes)
1990 })
1991 .expect_err("fault should fire after pack publish");
1992 assert!(err.to_string().contains("pack_install_after_publish_pack"));
1993
1994 assert!(dst_pack_path(&packs, &expected).exists());
1997 assert!(!dst_idx_path(&packs, &expected).exists());
1998
1999 let report = recover_pack_install_intents(&packs).unwrap();
2000 assert_eq!(report.completed, 1, "report={report:?}");
2001 assert!(dst_idx_path(&packs, &expected).exists());
2002 assert!(existing_pair_matches_pack_name(&packs, &expected).unwrap());
2003 }
2004
2005 fn intent_count_json(packs: &Path) -> usize {
2006 let dir = intent_root(packs);
2007 if !dir.exists() {
2008 return 0;
2009 }
2010 fs::read_dir(dir)
2011 .unwrap()
2012 .filter(|e| {
2013 e.as_ref()
2014 .map(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json"))
2015 .unwrap_or(false)
2016 })
2017 .count()
2018 }
2019
2020 #[test]
2021 fn metrics_snapshot_tracks_install_and_recover() {
2022 let before = pack_install_metrics_snapshot();
2024 let root = tempfile::tempdir().unwrap();
2025 let packs = root.path().join("packs");
2026 create_dir_all_durable(&packs).unwrap();
2027 let _ = install_pack_bytes_journaled(&packs, b"metrics-pack", &empty_idx_bytes()).unwrap();
2028 let after_install = pack_install_metrics_snapshot();
2029 assert!(after_install.installs_ok > before.installs_ok);
2030
2031 let install_id = "metrics-abort";
2033 create_dir_all_durable(&staging_dir(&packs, install_id)).unwrap();
2034 write_file_atomic(&staging_pack_path(&packs, install_id), b"p").unwrap();
2035 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
2036 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("cc"));
2037 intent.created_unix = 1;
2038 write_intent(&packs, &intent).unwrap();
2039 let before_abort = pack_install_metrics_snapshot();
2040 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
2041 assert_eq!(report.aborted, 1);
2042 let after_abort = pack_install_metrics_snapshot();
2043 assert!(after_abort.recover_aborted > before_abort.recover_aborted);
2044 }
2045
2046 #[cfg(unix)]
2047 #[test]
2048 fn assert_under_packs_rejects_staging_symlink_escape() {
2049 use std::os::unix::fs::symlink;
2050
2051 let root = tempfile::tempdir().unwrap();
2052 let packs = root.path().join("packs");
2053 create_dir_all_durable(&packs).unwrap();
2054 let outside = root.path().join("outside");
2055 create_dir_all_durable(&outside).unwrap();
2056
2057 symlink(&outside, packs.join(STAGING_DIR_NAME)).unwrap();
2059
2060 let stage = staging_dir(&packs, "id1");
2061 let err = assert_under_packs(&packs, &stage).unwrap_err();
2062 assert!(
2063 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2064 "err={err}"
2065 );
2066
2067 let err = ensure_journal_layout_safe(&packs).unwrap_err();
2069 assert!(
2070 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2071 "err={err}"
2072 );
2073 let err = install_pack_bytes_journaled(&packs, b"x", &empty_idx_bytes()).unwrap_err();
2074 assert!(
2075 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2076 "err={err}"
2077 );
2078 }
2079
2080 #[cfg(unix)]
2081 #[test]
2082 fn assert_under_packs_rejects_intent_root_symlink_escape() {
2083 use std::os::unix::fs::symlink;
2084
2085 let root = tempfile::tempdir().unwrap();
2086 let packs = root.path().join("packs");
2087 create_dir_all_durable(&packs).unwrap();
2088 let outside = root.path().join("outside-intent");
2089 create_dir_all_durable(&outside).unwrap();
2090 symlink(&outside, packs.join(INTENT_DIR_NAME)).unwrap();
2091
2092 let err = ensure_journal_layout_safe(&packs).unwrap_err();
2093 assert!(
2094 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2095 "err={err}"
2096 );
2097 }
2098
2099 #[cfg(unix)]
2100 #[test]
2101 fn assert_under_packs_rejects_pack_locks_symlink_escape() {
2102 use std::os::unix::fs::symlink;
2103
2104 let root = tempfile::tempdir().unwrap();
2105 let packs = root.path().join("packs");
2106 create_dir_all_durable(&packs).unwrap();
2107 let outside = root.path().join("outside-locks");
2108 create_dir_all_durable(&outside).unwrap();
2109 symlink(&outside, packs.join(PACK_LOCKS_DIR_NAME)).unwrap();
2110
2111 let err = ensure_journal_layout_safe(&packs).unwrap_err();
2112 assert!(
2113 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2114 "err={err}"
2115 );
2116 }
2117
2118 #[cfg(unix)]
2119 #[test]
2120 fn assert_under_packs_rejects_destination_file_symlink_escape() {
2121 use std::os::unix::fs::symlink;
2122
2123 let root = tempfile::tempdir().unwrap();
2124 let packs = root.path().join("packs");
2125 create_dir_all_durable(&packs).unwrap();
2126 let outside = root.path().join("evil.pack");
2127 fs::write(&outside, b"evil").unwrap();
2128
2129 let name = pack_id("symlink-dst");
2130 let dst = dst_pack_path(&packs, &name);
2131 symlink(&outside, &dst).unwrap();
2132
2133 let err = assert_under_packs(&packs, &dst).unwrap_err();
2134 assert!(
2135 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2136 "err={err}"
2137 );
2138 let pair = existing_pair_matches_pack_name(&packs, &name);
2140 assert!(pair.as_ref().map(|v| !v).unwrap_or(true), "pair={pair:?}");
2141 }
2142
2143 #[cfg(unix)]
2144 #[test]
2145 fn assert_under_packs_rejects_install_id_staging_symlink() {
2146 use std::os::unix::fs::symlink;
2147
2148 let root = tempfile::tempdir().unwrap();
2149 let packs = root.path().join("packs");
2150 create_dir_all_durable(&packs).unwrap();
2151 create_dir_all_durable(&staging_root(&packs)).unwrap();
2152 let outside = root.path().join("outside-stage-id");
2153 create_dir_all_durable(&outside).unwrap();
2154 let install_id = "symlink-stage-id";
2155 symlink(&outside, staging_dir(&packs, install_id)).unwrap();
2156
2157 let pack_path = staging_pack_path(&packs, install_id);
2158 let err = assert_under_packs(&packs, &pack_path).unwrap_err();
2159 assert!(
2160 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2161 "err={err}"
2162 );
2163 }
2164
2165 #[test]
2166 fn assert_under_packs_accepts_normal_reconstructed_paths() {
2167 let root = tempfile::tempdir().unwrap();
2168 let packs = root.path().join("packs");
2169 create_dir_all_durable(&packs).unwrap();
2170 let name = pack_id("normal");
2171 let install_id = "normal-id";
2172 assert_under_packs(&packs, &dst_pack_path(&packs, &name)).unwrap();
2173 assert_under_packs(&packs, &staging_pack_path(&packs, install_id)).unwrap();
2174 assert_under_packs(&packs, &intent_path(&packs, install_id)).unwrap();
2175 }
2176}