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 artifact_bytes: Vec<u8>,
914) -> io::Result<String> {
915 install_snapshot_pack_bytes_inner(
916 packs_dir,
917 pack_data,
918 index_data,
919 &[(artifact_id, artifact_bytes)],
920 )
921}
922
923pub(crate) fn install_snapshot_pack_bytes_with_commit_markers(
924 packs_dir: &Path,
925 pack_data: Vec<u8>,
926 index_data: Vec<u8>,
927 artifact_ids: &[ContentHash],
928) -> io::Result<String> {
929 let markers = artifact_ids
930 .iter()
931 .copied()
932 .map(|id| (id, Vec::new()))
933 .collect::<Vec<_>>();
934 install_snapshot_pack_bytes_inner(packs_dir, pack_data, index_data, &markers)
935}
936
937fn install_snapshot_pack_bytes_inner(
938 packs_dir: &Path,
939 pack_data: Vec<u8>,
940 index_data: Vec<u8>,
941 commit_markers: &[(ContentHash, Vec<u8>)],
942) -> io::Result<String> {
943 ensure_journal_layout_safe(packs_dir)?;
944 let digest = *blake3::hash(&pack_data).as_bytes();
945 let pack_name = digest_to_pack_name(&digest);
946 let _guard = acquire_pack_name_lock(packs_dir, &pack_name)?;
947
948 let pack_path = dst_pack_path(packs_dir, &pack_name);
949 if existing_pair_matches_digest(packs_dir, &pack_name, &digest)? {
950 for (artifact_id, artifact_bytes) in commit_markers {
951 let marker = snapshot_commit_marker_path(&pack_path, artifact_id);
952 if !marker.exists() {
953 let mut file = OpenOptions::new()
954 .write(true)
955 .create_new(true)
956 .open(marker)?;
957 file.write_all(artifact_bytes)?;
958 }
959 }
960 sync_directory(packs_dir)?;
961 return Ok(pack_name);
962 }
963
964 let dst_pack = dst_pack_path(packs_dir, &pack_name);
965 let dst_idx = dst_idx_path(packs_dir, &pack_name);
966 assert_under_packs(packs_dir, &dst_pack)?;
967 assert_under_packs(packs_dir, &dst_idx)?;
968 if dst_pack.exists() {
969 fs::remove_file(&dst_pack)?;
970 }
971 if dst_idx.exists() {
972 fs::remove_file(&dst_idx)?;
973 }
974
975 let tmp_pack = temp_path(&dst_pack);
976 let tmp_idx = temp_path(&dst_idx);
977 let result = (|| {
978 stage_snapshot_pack_pair_durable(&tmp_pack, pack_data, &tmp_idx, index_data)?;
979 fs::rename(&tmp_pack, &dst_pack)?;
980 fault_inject::maybe_fail_at("snapshot_pack_after_publish_pack")?;
981 fs::rename(&tmp_idx, &dst_idx)?;
982 fault_inject::maybe_fail_at("snapshot_pack_after_publish_idx")?;
983 for (artifact_id, artifact_bytes) in commit_markers {
984 let marker = snapshot_commit_marker_path(&dst_pack, artifact_id);
985 let mut file = OpenOptions::new()
986 .write(true)
987 .create_new(true)
988 .open(marker)?;
989 file.write_all(artifact_bytes)?;
990 }
991 sync_directory(packs_dir)?;
992 Ok(pack_name.clone())
993 })();
994 if result.is_err() {
995 let _ = fs::remove_file(&tmp_pack);
996 let _ = fs::remove_file(&tmp_idx);
997 }
998 result
999}
1000
1001fn stage_snapshot_pack_pair_durable(
1002 pack_path: &Path,
1003 pack_data: Vec<u8>,
1004 index_path: &Path,
1005 index_data: Vec<u8>,
1006) -> io::Result<()> {
1007 let mut pack = OpenOptions::new()
1008 .write(true)
1009 .create_new(true)
1010 .open(pack_path)?;
1011 pack.write_all(&pack_data)?;
1012 let mut index = OpenOptions::new()
1013 .write(true)
1014 .create_new(true)
1015 .open(index_path)?;
1016 index.write_all(&index_data)?;
1017
1018 let (pack_sync, index_sync) = thread::scope(|scope| {
1019 let pack_sync = scope.spawn(move || sync_file(&pack, pack_path));
1020 let index_sync = scope.spawn(move || sync_file(&index, index_path));
1021 (pack_sync.join(), index_sync.join())
1022 });
1023 pack_sync.map_err(|_| io::Error::other("snapshot pack sync worker panicked"))??;
1024 index_sync.map_err(|_| io::Error::other("snapshot index sync worker panicked"))??;
1025 Ok(())
1026}
1027
1028fn new_install_id() -> String {
1029 let t = unix_now() as u64;
1030 let r: u64 = rand::random();
1031 format!("{t:016x}-{r:016x}")
1032}
1033
1034pub fn install_pack_bytes_journaled(
1036 packs_dir: &Path,
1037 pack_data: &[u8],
1038 index_data: &[u8],
1039) -> io::Result<String> {
1040 match install_pack_bytes_journaled_inner(packs_dir, pack_data, index_data) {
1041 Ok(name) => {
1042 metric_inc(&METRIC_INSTALLS_OK);
1043 Ok(name)
1044 }
1045 Err(e) => {
1046 metric_inc(&METRIC_INSTALLS_ERR);
1047 Err(e)
1048 }
1049 }
1050}
1051
1052fn install_pack_bytes_journaled_inner(
1053 packs_dir: &Path,
1054 pack_data: &[u8],
1055 index_data: &[u8],
1056) -> io::Result<String> {
1057 ensure_journal_layout_safe(packs_dir)?;
1058 let digest = *blake3::hash(pack_data).as_bytes();
1060 let pack_name = digest_to_pack_name(&digest);
1061
1062 if existing_pair_matches_digest(packs_dir, &pack_name, &digest)? {
1063 return Ok(pack_name);
1064 }
1065
1066 let install_id = new_install_id();
1068 validate_install_id(&install_id)?;
1069 let stage = staging_dir(packs_dir, &install_id);
1070 assert_under_packs(packs_dir, &stage)?;
1071 create_dir_all_durable(&stage)?;
1072 let staging_pack = staging_pack_path(packs_dir, &install_id);
1073 let staging_idx = staging_idx_path(packs_dir, &install_id);
1074 assert_under_packs(packs_dir, &staging_pack)?;
1075 assert_under_packs(packs_dir, &staging_idx)?;
1076 write_file_atomic(&staging_pack, pack_data)?;
1077 fault_inject::maybe_fail_at("pack_install_after_stage_pack")?;
1078 write_file_atomic(&staging_idx, index_data)?;
1079 fault_inject::maybe_fail_at("pack_install_after_stage_idx")?;
1080
1081 let _guard = acquire_pack_name_lock(packs_dir, &pack_name)?;
1083 fault_inject::maybe_fail_at("pack_install_after_pack_lock")?;
1084
1085 if existing_pair_matches_digest(packs_dir, &pack_name, &digest)? {
1086 remove_staging(packs_dir, &install_id);
1087 return Ok(pack_name);
1088 }
1089 let dst_pack = dst_pack_path(packs_dir, &pack_name);
1090 let dst_idx = dst_idx_path(packs_dir, &pack_name);
1091 assert_under_packs(packs_dir, &dst_pack)?;
1092 assert_under_packs(packs_dir, &dst_idx)?;
1093 if dst_pack.exists() && !dst_idx.exists() {
1094 let _ = fs::remove_file(&dst_pack);
1095 }
1096 if dst_pack.exists() && dst_idx.exists() {
1097 if !existing_pair_matches_digest(packs_dir, &pack_name, &digest)? {
1098 let _ = fs::remove_file(&dst_pack);
1099 let _ = fs::remove_file(&dst_idx);
1100 } else {
1101 remove_staging(packs_dir, &install_id);
1102 return Ok(pack_name);
1103 }
1104 }
1105
1106 let mut intent = PackInstallIntent::new(install_id.clone(), pack_name.clone());
1107 write_intent(packs_dir, &intent)?;
1108 fault_inject::maybe_fail_at("pack_install_after_intent_prepared")?;
1109
1110 publish_file_durable(&staging_pack, &dst_pack)?;
1111 fault_inject::maybe_fail_at("pack_install_after_publish_pack")?;
1112 intent.phase = PackInstallPhase::PackPublished;
1113 write_intent(packs_dir, &intent)?;
1114 fault_inject::maybe_fail_at("pack_install_after_intent_pack_published")?;
1115
1116 publish_file_durable(&staging_idx, &dst_idx)?;
1117 fault_inject::maybe_fail_at("pack_install_after_publish_idx")?;
1118 remove_staging(packs_dir, &install_id);
1119 remove_intent(packs_dir, &install_id)?;
1120 fault_inject::maybe_fail_at("pack_install_after_intent_removed")?;
1121 Ok(pack_name)
1122}
1123
1124pub fn install_pack_files_journaled(
1126 packs_dir: &Path,
1127 src_pack_path: &Path,
1128 src_index_path: &Path,
1129 pack_name: &str,
1130) -> io::Result<()> {
1131 match install_pack_files_journaled_inner(packs_dir, src_pack_path, src_index_path, pack_name) {
1132 Ok(()) => {
1133 metric_inc(&METRIC_INSTALLS_OK);
1134 Ok(())
1135 }
1136 Err(e) => {
1137 metric_inc(&METRIC_INSTALLS_ERR);
1138 Err(e)
1139 }
1140 }
1141}
1142
1143fn install_pack_files_journaled_inner(
1144 packs_dir: &Path,
1145 src_pack_path: &Path,
1146 src_index_path: &Path,
1147 pack_name: &str,
1148) -> io::Result<()> {
1149 let expected = pack_name_to_digest(pack_name)?;
1151 ensure_journal_layout_safe(packs_dir)?;
1152
1153 if existing_pair_matches_digest(packs_dir, pack_name, &expected)? {
1154 let _ = fs::remove_file(src_pack_path);
1155 let _ = fs::remove_file(src_index_path);
1156 return Ok(());
1157 }
1158
1159 let install_id = new_install_id();
1160 validate_install_id(&install_id)?;
1161 let stage = staging_dir(packs_dir, &install_id);
1162 assert_under_packs(packs_dir, &stage)?;
1163 create_dir_all_durable(&stage)?;
1164 let staging_pack = staging_pack_path(packs_dir, &install_id);
1165 let staging_idx = staging_idx_path(packs_dir, &install_id);
1166 assert_under_packs(packs_dir, &staging_pack)?;
1167 assert_under_packs(packs_dir, &staging_idx)?;
1168 publish_file_durable(src_pack_path, &staging_pack)?;
1169 fault_inject::maybe_fail_at("pack_install_stream_after_stage_pack")?;
1170 publish_file_durable(src_index_path, &staging_idx)?;
1171 fault_inject::maybe_fail_at("pack_install_stream_after_stage_idx")?;
1172
1173 let _guard = acquire_pack_name_lock(packs_dir, pack_name)?;
1174 if existing_pair_matches_digest(packs_dir, pack_name, &expected)? {
1175 remove_staging(packs_dir, &install_id);
1176 return Ok(());
1177 }
1178 let dst_pack = dst_pack_path(packs_dir, pack_name);
1179 let dst_idx = dst_idx_path(packs_dir, pack_name);
1180 assert_under_packs(packs_dir, &dst_pack)?;
1181 assert_under_packs(packs_dir, &dst_idx)?;
1182 if dst_pack.exists() && !dst_idx.exists() {
1183 let _ = fs::remove_file(&dst_pack);
1184 }
1185 if dst_pack.exists() && dst_idx.exists() {
1186 if existing_pair_matches_digest(packs_dir, pack_name, &expected)? {
1187 remove_staging(packs_dir, &install_id);
1188 return Ok(());
1189 }
1190 let _ = fs::remove_file(&dst_pack);
1191 let _ = fs::remove_file(&dst_idx);
1192 }
1193
1194 let mut intent = PackInstallIntent::new(install_id.clone(), pack_name.to_string());
1195 write_intent(packs_dir, &intent)?;
1196 fault_inject::maybe_fail_at("pack_install_stream_after_intent_prepared")?;
1197
1198 publish_file_durable(&staging_pack, &dst_pack)?;
1199 fault_inject::maybe_fail_at("pack_install_stream_after_publish_pack")?;
1200 intent.phase = PackInstallPhase::PackPublished;
1201 write_intent(packs_dir, &intent)?;
1202 fault_inject::maybe_fail_at("pack_install_stream_after_intent_pack_published")?;
1203
1204 publish_file_durable(&staging_idx, &dst_idx)?;
1205 fault_inject::maybe_fail_at("pack_install_stream_after_publish_idx")?;
1206 remove_staging(packs_dir, &install_id);
1207 remove_intent(packs_dir, &install_id)?;
1208 Ok(())
1209}
1210
1211#[cfg(test)]
1216mod tests {
1217 use super::*;
1218 use crate::fs_atomic::write_file_atomic;
1219
1220 fn write_src(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
1221 let p = dir.join(name);
1222 write_file_atomic(&p, bytes).unwrap();
1223 p
1224 }
1225
1226 fn pack_id(seed: &str) -> String {
1228 digest_to_pack_name(blake3::hash(seed.as_bytes()).as_bytes())
1229 }
1230
1231 fn empty_idx_bytes() -> Vec<u8> {
1233 crate::store::pack::PackIndex::new().to_bytes()
1234 }
1235
1236 #[test]
1237 fn validate_ids_reject_path_traversal() {
1238 assert!(validate_install_id("../evil").is_err());
1239 assert!(validate_install_id("a/b").is_err());
1240 assert!(validate_install_id("").is_err());
1241 assert!(validate_pack_name("../x").is_err());
1242 assert!(validate_pack_name("not-hex!").is_err());
1243 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());
1246 assert!(validate_pack_name(&pack_id("ok")).is_ok());
1247 assert_eq!(pack_id("ok").len(), 64);
1248 }
1249
1250 #[test]
1251 fn pack_name_digest_roundtrip_and_native_equality() {
1252 let body = b"digest-native-eq";
1253 let digest = *blake3::hash(body).as_bytes();
1254 let name = digest_to_pack_name(&digest);
1255 let parsed = pack_name_to_digest(&name).unwrap();
1256 assert_eq!(parsed, digest);
1257 let root = tempfile::tempdir().unwrap();
1259 let packs = root.path().join("packs");
1260 create_dir_all_durable(&packs).unwrap();
1261 write_file_atomic(&dst_pack_path(&packs, &name), body).unwrap();
1262 write_file_atomic(&dst_idx_path(&packs, &name), &empty_idx_bytes()).unwrap();
1263 assert!(existing_pair_matches_digest(&packs, &name, &digest).unwrap());
1264 let mut wrong = digest;
1265 wrong[0] ^= 0xff;
1266 assert!(!existing_pair_matches_digest(&packs, &name, &wrong).unwrap());
1267 }
1268
1269 #[test]
1270 fn malicious_intent_paths_ignored_reconstructed_from_packs_dir() {
1271 let root = tempfile::tempdir().unwrap();
1274 let packs = root.path().join("packs");
1275 create_dir_all_durable(&packs).unwrap();
1276 let outside = root.path().join("outside.txt");
1277 fs::write(&outside, b"secret").unwrap();
1278
1279 let install_id = "malicious1";
1280 let pack_name = pack_id("malicious-pack");
1281 let intent_dir = intent_root(&packs);
1282 create_dir_all_durable(&intent_dir).unwrap();
1283 let forged = serde_json::json!({
1284 "version": 2,
1285 "install_id": install_id,
1286 "pack_name": pack_name,
1287 "phase": "prepared",
1288 "created_unix": 1,
1289 "staging_pack": outside.display().to_string(),
1290 "dst_pack": outside.display().to_string(),
1291 "dst_idx": outside.display().to_string(),
1292 });
1293 fs::write(
1294 intent_path(&packs, install_id),
1295 serde_json::to_vec_pretty(&forged).unwrap(),
1296 )
1297 .unwrap();
1298
1299 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1301 assert_eq!(report.aborted, 1);
1302 assert_eq!(fs::read(&outside).unwrap(), b"secret");
1304 }
1305
1306 #[test]
1307 fn quarantine_unknown_version_preserves_file() {
1308 let root = tempfile::tempdir().unwrap();
1309 let packs = root.path().join("packs");
1310 create_dir_all_durable(&packs).unwrap();
1311 let intent_dir = intent_root(&packs);
1312 create_dir_all_durable(&intent_dir).unwrap();
1313 let path = intent_dir.join("weird.json");
1314 let pn = pack_id("weird");
1315 fs::write(
1316 &path,
1317 format!(
1318 r#"{{"version":99,"install_id":"x","pack_name":"{pn}","phase":"prepared","created_unix":1}}"#
1319 ),
1320 )
1321 .unwrap();
1322
1323 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1324 assert_eq!(report.quarantined, 1);
1325 assert!(!path.exists());
1326 let q = quarantine_root(&packs);
1327 assert!(q.exists());
1328 assert!(fs::read_dir(&q).unwrap().count() >= 1);
1329 }
1330
1331 #[test]
1332 fn short_pack_name_in_intent_is_quarantined() {
1333 let root = tempfile::tempdir().unwrap();
1334 let packs = root.path().join("packs");
1335 create_dir_all_durable(&packs).unwrap();
1336 create_dir_all_durable(&intent_root(&packs)).unwrap();
1337 let path = intent_path(&packs, "shortname");
1338 fs::write(
1339 &path,
1340 br#"{"version":2,"install_id":"shortname","pack_name":"aa","phase":"prepared","created_unix":1}"#,
1341 )
1342 .unwrap();
1343 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1344 assert_eq!(report.quarantined, 1);
1345 assert!(!path.exists());
1346 }
1347
1348 #[test]
1349 fn quarantine_malformed_json() {
1350 let root = tempfile::tempdir().unwrap();
1351 let packs = root.path().join("packs");
1352 create_dir_all_durable(&packs).unwrap();
1353 let intent_dir = intent_root(&packs);
1354 create_dir_all_durable(&intent_dir).unwrap();
1355 let path = intent_dir.join("bad.json");
1356 fs::write(&path, b"not-json{{{{").unwrap();
1357
1358 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1359 assert_eq!(report.quarantined, 1);
1360 assert!(!path.exists());
1361 }
1362
1363 #[test]
1364 fn existing_pair_requires_hash_match_and_valid_index() {
1365 let root = tempfile::tempdir().unwrap();
1366 let packs = root.path().join("packs");
1367 create_dir_all_durable(&packs).unwrap();
1368 let body = b"pack-body-xyz";
1369 let name = format!("{}", blake3::hash(body).to_hex());
1370 write_file_atomic(&dst_pack_path(&packs, &name), body).unwrap();
1371 write_file_atomic(&dst_idx_path(&packs, &name), &empty_idx_bytes()).unwrap();
1372 assert!(existing_pair_matches_pack_name(&packs, &name).unwrap());
1373
1374 write_file_atomic(&dst_idx_path(&packs, &name), b"not-an-index").unwrap();
1376 assert!(!existing_pair_matches_pack_name(&packs, &name).unwrap());
1377
1378 let wrong = pack_id("wrong-name-for-body");
1380 write_file_atomic(&dst_pack_path(&packs, &wrong), body).unwrap();
1381 write_file_atomic(&dst_idx_path(&packs, &wrong), &empty_idx_bytes()).unwrap();
1382 assert!(!existing_pair_matches_pack_name(&packs, &wrong).unwrap());
1383 }
1384
1385 #[test]
1386 fn install_rejects_idempotent_false_pair() {
1387 let root = tempfile::tempdir().unwrap();
1388 let packs = root.path().join("packs");
1389 create_dir_all_durable(&packs).unwrap();
1390 let pack_bytes = b"real-pack-content-111";
1391 let idx_bytes = empty_idx_bytes();
1392 let name = format!("{}", blake3::hash(pack_bytes).to_hex());
1393
1394 write_file_atomic(&dst_pack_path(&packs, &name), b"wrong-content").unwrap();
1396 write_file_atomic(&dst_idx_path(&packs, &name), b"x").unwrap();
1397 assert!(!existing_pair_matches_pack_name(&packs, &name).unwrap());
1398
1399 let out = install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes).unwrap();
1400 assert_eq!(out, name);
1401 assert_eq!(fs::read(dst_pack_path(&packs, &name)).unwrap(), pack_bytes);
1402 }
1403
1404 #[test]
1405 fn journaled_install_produces_pair_and_cleans_intent() {
1406 let root = tempfile::tempdir().unwrap();
1407 let packs = root.path().join("packs");
1408 create_dir_all_durable(&packs).unwrap();
1409 let src_dir = root.path().join("src");
1410 create_dir_all_durable(&src_dir).unwrap();
1411
1412 let pack_bytes = b"fake-pack-bytes-aaa";
1413 let idx_bytes = b"fake-idx-bytes-aaa";
1414 let src_pack = write_src(&src_dir, "p.pack", pack_bytes);
1415 let src_idx = write_src(&src_dir, "p.idx", idx_bytes);
1416 let name = format!("{}", blake3::hash(pack_bytes).to_hex());
1417
1418 install_pack_files_journaled(&packs, &src_pack, &src_idx, &name).unwrap();
1419
1420 assert!(dst_pack_path(&packs, &name).exists());
1421 assert!(dst_idx_path(&packs, &name).exists());
1422 assert_eq!(fs::read(dst_pack_path(&packs, &name)).unwrap(), pack_bytes);
1423 assert!(
1424 !intent_root(&packs).exists()
1425 || fs::read_dir(intent_root(&packs))
1426 .unwrap()
1427 .filter(|e| {
1428 e.as_ref()
1429 .map(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json"))
1430 .unwrap_or(false)
1431 })
1432 .count()
1433 == 0
1434 );
1435 }
1436
1437 #[test]
1438 fn recover_pack_published_completes_from_staging() {
1439 let root = tempfile::tempdir().unwrap();
1440 let packs = root.path().join("packs");
1441 create_dir_all_durable(&packs).unwrap();
1442
1443 let name = pack_id("deadbeef-seed");
1444 let install_id = "test-install-1";
1445 validate_install_id(install_id).unwrap();
1446 validate_pack_name(&name).unwrap();
1447 let stage = staging_dir(&packs, install_id);
1448 create_dir_all_durable(&stage).unwrap();
1449 write_file_atomic(&staging_pack_path(&packs, install_id), b"pack-body").unwrap();
1450 write_file_atomic(&staging_idx_path(&packs, install_id), b"idx-body").unwrap();
1451
1452 let dst_pack = dst_pack_path(&packs, &name);
1453 publish_file_durable(&staging_pack_path(&packs, install_id), &dst_pack).unwrap();
1454
1455 let intent = PackInstallIntent {
1456 version: PACK_INSTALL_INTENT_VERSION,
1457 install_id: install_id.to_string(),
1458 pack_name: name.clone(),
1459 phase: PackInstallPhase::PackPublished,
1460 created_unix: 1,
1461 };
1462 write_intent(&packs, &intent).unwrap();
1463
1464 let report = recover_pack_install_intents(&packs).unwrap();
1465 assert_eq!(report.intents_seen, 1);
1466 assert_eq!(report.completed, 1);
1467 assert!(dst_pack.exists());
1468 assert!(dst_idx_path(&packs, &name).exists());
1469 assert_eq!(fs::read(dst_idx_path(&packs, &name)).unwrap(), b"idx-body");
1470 assert!(!intent_path(&packs, install_id).exists());
1471 }
1472
1473 #[test]
1474 fn recover_prepared_aborts_without_finals_when_expired() {
1475 let root = tempfile::tempdir().unwrap();
1476 let packs = root.path().join("packs");
1477 create_dir_all_durable(&packs).unwrap();
1478 let install_id = "prep-abort";
1479 let stage = staging_dir(&packs, install_id);
1480 create_dir_all_durable(&stage).unwrap();
1481 write_file_atomic(&staging_pack_path(&packs, install_id), b"p").unwrap();
1482 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
1483
1484 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("aa"));
1485 intent.created_unix = 1;
1486 write_intent(&packs, &intent).unwrap();
1487
1488 let report = recover_pack_install_intents_with_ttl(&packs, Some(60)).unwrap();
1489 assert_eq!(report.aborted, 1);
1490 assert!(!dst_pack_path(&packs, &pack_id("aa")).exists());
1491 assert!(!intent_path(&packs, install_id).exists());
1492 assert!(!stage.exists());
1493 }
1494
1495 #[test]
1496 fn recover_prepared_fresh_skips_in_progress() {
1497 let root = tempfile::tempdir().unwrap();
1498 let packs = root.path().join("packs");
1499 create_dir_all_durable(&packs).unwrap();
1500 let install_id = "live-prep";
1501 let stage = staging_dir(&packs, install_id);
1502 create_dir_all_durable(&stage).unwrap();
1503 let staging_pack = staging_pack_path(&packs, install_id);
1504 let staging_idx = staging_idx_path(&packs, install_id);
1505 write_file_atomic(&staging_pack, b"live-p").unwrap();
1506 write_file_atomic(&staging_idx, b"live-i").unwrap();
1507
1508 let intent = PackInstallIntent::new(install_id.into(), pack_id("bb"));
1509 write_intent(&packs, &intent).unwrap();
1510
1511 let report = recover_pack_install_intents_with_ttl(&packs, Some(86_400)).unwrap();
1512 assert_eq!(report.skipped_in_progress, 1);
1513 assert_eq!(report.aborted, 0);
1514 assert!(staging_pack.exists());
1515 assert!(intent_path(&packs, install_id).exists());
1516 }
1517
1518 #[test]
1519 fn recover_pack_published_without_staging_idx_aborts_orphan_pack() {
1520 let root = tempfile::tempdir().unwrap();
1521 let packs = root.path().join("packs");
1522 create_dir_all_durable(&packs).unwrap();
1523 let name = pack_id("cc");
1524 let install_id = "orph-1";
1525 let dst_pack = dst_pack_path(&packs, &name);
1526 write_file_atomic(&dst_pack, b"only-pack").unwrap();
1527
1528 let intent = PackInstallIntent {
1529 version: PACK_INSTALL_INTENT_VERSION,
1530 install_id: install_id.into(),
1531 pack_name: name,
1532 phase: PackInstallPhase::PackPublished,
1533 created_unix: 1,
1534 };
1535 write_intent(&packs, &intent).unwrap();
1536
1537 let report = recover_pack_install_intents(&packs).unwrap();
1538 assert_eq!(report.aborted, 1);
1539 assert!(!dst_pack.exists());
1540 assert!(!intent_path(&packs, install_id).exists());
1541 }
1542
1543 #[test]
1544 fn pack_lock_prevents_recover_aborting_live_expired_looking_install() {
1545 use std::{
1546 sync::{Arc, Barrier},
1547 thread,
1548 time::Duration,
1549 };
1550
1551 let root = tempfile::tempdir().unwrap();
1554 let packs = Arc::new(root.path().join("packs"));
1555 create_dir_all_durable(&packs).unwrap();
1556
1557 let planted_under_lock = Arc::new(Barrier::new(2));
1558 let both_done = Arc::new(Barrier::new(2));
1559
1560 let packs_a = Arc::clone(&packs);
1561 let planted_a = Arc::clone(&planted_under_lock);
1562 let done_a = Arc::clone(&both_done);
1563
1564 let installer = thread::spawn(move || {
1565 let packs = packs_a.as_path();
1566 let guard = acquire_pack_name_lock(packs, &pack_id("dd")).expect("pack lock");
1567
1568 let install_id = "flock-live";
1569 let stage = staging_dir(packs, install_id);
1570 create_dir_all_durable(&stage).unwrap();
1571 let staging_pack = staging_pack_path(packs, install_id);
1572 let staging_idx = staging_idx_path(packs, install_id);
1573 write_file_atomic(&staging_pack, b"flock-pack").unwrap();
1574 write_file_atomic(&staging_idx, b"flock-idx").unwrap();
1575 let dst_pack = dst_pack_path(packs, &pack_id("dd"));
1576 let dst_idx = dst_idx_path(packs, &pack_id("dd"));
1577
1578 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("dd"));
1579 intent.created_unix = 1; write_intent(packs, &intent).unwrap();
1581
1582 planted_a.wait();
1583 thread::sleep(Duration::from_millis(60));
1584
1585 assert!(staging_pack.exists() && staging_idx.exists());
1586 assert!(intent_path(packs, install_id).exists());
1587
1588 publish_file_durable(&staging_pack, &dst_pack).unwrap();
1589 intent.phase = PackInstallPhase::PackPublished;
1590 write_intent(packs, &intent).unwrap();
1591 publish_file_durable(&staging_idx, &dst_idx).unwrap();
1592 remove_staging(packs, install_id);
1593 remove_intent(packs, install_id).unwrap();
1594
1595 drop(guard);
1596 assert!(dst_pack.exists() && dst_idx.exists());
1597 done_a.wait();
1598 });
1599
1600 let packs_b = Arc::clone(&packs);
1601 let planted_b = Arc::clone(&planted_under_lock);
1602 let done_b = Arc::clone(&both_done);
1603
1604 let recoverer = thread::spawn(move || {
1605 let packs = packs_b.as_path();
1606 planted_b.wait();
1607 let mid = recover_pack_install_intents_with_ttl(packs, Some(1))
1609 .expect("recover under pack lock");
1610 assert_eq!(mid.aborted, 0, "must not abort live install: {mid:?}");
1611 assert!(
1612 mid.skipped_in_progress >= 1 || dst_pack_path(packs, &pack_id("dd")).exists(),
1613 "either skip in-progress or install already finished: {mid:?}"
1614 );
1615 done_b.wait();
1616 assert!(dst_pack_path(packs, &pack_id("dd")).exists());
1618 assert!(dst_idx_path(packs, &pack_id("dd")).exists());
1619 assert!(!intent_path(packs, "flock-live").exists());
1620 });
1621
1622 installer.join().expect("installer");
1623 recoverer.join().expect("recoverer");
1624 }
1625
1626 #[test]
1627 fn recover_prepared_with_pack_and_staging_idx_completes() {
1628 let root = tempfile::tempdir().unwrap();
1629 let packs = root.path().join("packs");
1630 create_dir_all_durable(&packs).unwrap();
1631 let install_id = "prep-complete";
1632 let stage = staging_dir(&packs, install_id);
1633 create_dir_all_durable(&stage).unwrap();
1634 write_file_atomic(&staging_pack_path(&packs, install_id), b"pack-x").unwrap();
1635 write_file_atomic(&staging_idx_path(&packs, install_id), b"idx-x").unwrap();
1636 let dst_pack = dst_pack_path(&packs, &pack_id("ee"));
1637 publish_file_durable(&staging_pack_path(&packs, install_id), &dst_pack).unwrap();
1638
1639 let intent = PackInstallIntent::new(install_id.into(), pack_id("ee"));
1640 write_intent(&packs, &intent).unwrap();
1641
1642 let report = recover_pack_install_intents(&packs).unwrap();
1643 assert_eq!(report.completed, 1);
1644 assert!(dst_pack.exists());
1645 assert!(dst_idx_path(&packs, &pack_id("ee")).exists());
1646 assert_eq!(
1647 fs::read(dst_idx_path(&packs, &pack_id("ee"))).unwrap(),
1648 b"idx-x"
1649 );
1650 }
1651
1652 #[test]
1653 fn journaled_install_idempotent_when_pair_exists() {
1654 let root = tempfile::tempdir().unwrap();
1655 let packs = root.path().join("packs");
1656 create_dir_all_durable(&packs).unwrap();
1657 let pack_bytes = b"idemp-pack-bytes";
1658 let name = format!("{}", blake3::hash(pack_bytes).to_hex());
1659 write_file_atomic(&dst_pack_path(&packs, &name), pack_bytes).unwrap();
1660 write_file_atomic(&dst_idx_path(&packs, &name), &empty_idx_bytes()).unwrap();
1661 assert!(existing_pair_matches_pack_name(&packs, &name).unwrap());
1662
1663 let src_dir = root.path().join("src");
1664 create_dir_all_durable(&src_dir).unwrap();
1665 let src_pack = write_src(&src_dir, "a", b"other");
1666 let src_idx = write_src(&src_dir, "b", b"other-i");
1667
1668 install_pack_files_journaled(&packs, &src_pack, &src_idx, &name).unwrap();
1669 assert_eq!(fs::read(dst_pack_path(&packs, &name)).unwrap(), pack_bytes);
1670 }
1671
1672 #[test]
1673 fn install_pack_bytes_journaled_happy_path() {
1674 let root = tempfile::tempdir().unwrap();
1675 let packs = root.path().join("packs");
1676 create_dir_all_durable(&packs).unwrap();
1677
1678 let pack_bytes = b"in-memory-pack-body-zzz";
1679 let idx_bytes = empty_idx_bytes();
1680 let expected_name = format!("{}", blake3::hash(pack_bytes).to_hex());
1681
1682 let name = install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes).unwrap();
1683 assert_eq!(name, expected_name);
1684 assert!(existing_pair_matches_pack_name(&packs, &name).unwrap());
1685
1686 let name2 = install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes).unwrap();
1687 assert_eq!(name2, expected_name);
1688 }
1689
1690 #[test]
1691 fn snapshot_pack_install_publishes_pair_without_an_intent() {
1692 let root = tempfile::tempdir().unwrap();
1693 let packs = root.path().join("packs");
1694 let pack_bytes = b"snapshot-pack-body".to_vec();
1695 let idx_bytes = empty_idx_bytes();
1696 let expected_name = format!("{}", blake3::hash(&pack_bytes).to_hex());
1697
1698 let name = install_snapshot_pack_bytes(&packs, pack_bytes, idx_bytes).unwrap();
1699 assert_eq!(name, expected_name);
1700 assert!(existing_pair_matches_pack_name(&packs, &name).unwrap());
1701 assert_eq!(intent_count_json(&packs), 0);
1702 }
1703
1704 #[test]
1705 fn snapshot_pack_install_repairs_an_interrupted_pair_before_commit() {
1706 let root = tempfile::tempdir().unwrap();
1707 let packs = root.path().join("packs");
1708 let pack_bytes = b"interrupted-snapshot-pack".to_vec();
1709 let idx_bytes = empty_idx_bytes();
1710 let expected_name = format!("{}", blake3::hash(&pack_bytes).to_hex());
1711
1712 let err = fault_inject::with_fault_points(&["snapshot_pack_after_publish_pack"], || {
1713 install_snapshot_pack_bytes(&packs, pack_bytes.clone(), idx_bytes.clone())
1714 })
1715 .expect_err("fault should stop publication before the index rename");
1716 assert!(err.to_string().contains("snapshot_pack_after_publish_pack"));
1717 assert!(dst_pack_path(&packs, &expected_name).exists());
1718 assert!(!dst_idx_path(&packs, &expected_name).exists());
1719
1720 let repaired = install_snapshot_pack_bytes(&packs, pack_bytes, idx_bytes).unwrap();
1721 assert_eq!(repaired, expected_name);
1722 assert!(existing_pair_matches_pack_name(&packs, &repaired).unwrap());
1723 }
1724
1725 #[test]
1726 fn committed_snapshot_marker_appears_only_after_the_complete_pair() {
1727 let root = tempfile::tempdir().unwrap();
1728 let packs = root.path().join("packs");
1729 let pack_bytes = b"authoritative-snapshot-pack".to_vec();
1730 let idx_bytes = empty_idx_bytes();
1731 let expected_name = format!("{}", blake3::hash(&pack_bytes).to_hex());
1732 let artifact_id = ContentHash::compute(b"snapshot artifact");
1733 let marker =
1734 snapshot_commit_marker_path(&dst_pack_path(&packs, &expected_name), &artifact_id);
1735
1736 let err = fault_inject::with_fault_points(&["snapshot_pack_after_publish_idx"], || {
1737 install_committed_snapshot_pack_bytes(
1738 &packs,
1739 pack_bytes.clone(),
1740 idx_bytes.clone(),
1741 artifact_id,
1742 b"snapshot artifact metadata".to_vec(),
1743 )
1744 })
1745 .expect_err("fault should stop publication before the commit marker");
1746 assert!(err.to_string().contains("snapshot_pack_after_publish_idx"));
1747 assert!(existing_pair_matches_pack_name(&packs, &expected_name).unwrap());
1748 assert!(!marker.exists());
1749
1750 let repaired = install_committed_snapshot_pack_bytes(
1751 &packs,
1752 pack_bytes,
1753 idx_bytes,
1754 artifact_id,
1755 b"snapshot artifact metadata".to_vec(),
1756 )
1757 .unwrap();
1758 assert_eq!(repaired, expected_name);
1759 assert!(marker.exists());
1760 assert_eq!(fs::read(marker).unwrap(), b"snapshot artifact metadata");
1761 }
1762
1763 #[test]
1764 fn ttl_aborts_old_prepared_intent() {
1765 let root = tempfile::tempdir().unwrap();
1766 let packs = root.path().join("packs");
1767 create_dir_all_durable(&packs).unwrap();
1768 let install_id = "ttl-prep";
1769 let stage = staging_dir(&packs, install_id);
1770 create_dir_all_durable(&stage).unwrap();
1771 write_file_atomic(&staging_pack_path(&packs, install_id), b"stale-p").unwrap();
1772 write_file_atomic(&staging_idx_path(&packs, install_id), b"stale-i").unwrap();
1773
1774 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("ff"));
1775 intent.created_unix = 1;
1776 write_intent(&packs, &intent).unwrap();
1777
1778 let report = recover_pack_install_intents_with_ttl(&packs, Some(60)).unwrap();
1779 assert_eq!(report.aborted, 1);
1780 assert!(!intent_path(&packs, install_id).exists());
1781 assert!(!stage.exists());
1782 }
1783
1784 #[test]
1785 fn complete_preferred_over_ttl_when_staging_idx_present() {
1786 let root = tempfile::tempdir().unwrap();
1787 let packs = root.path().join("packs");
1788 create_dir_all_durable(&packs).unwrap();
1789
1790 let name = pack_id("11");
1791 let install_id = "ttl-complete-1";
1792 let stage = staging_dir(&packs, install_id);
1793 create_dir_all_durable(&stage).unwrap();
1794 write_file_atomic(&staging_pack_path(&packs, install_id), b"pack-ttl").unwrap();
1795 write_file_atomic(&staging_idx_path(&packs, install_id), b"idx-ttl").unwrap();
1796
1797 let dst_pack = dst_pack_path(&packs, &name);
1798 publish_file_durable(&staging_pack_path(&packs, install_id), &dst_pack).unwrap();
1799
1800 let intent = PackInstallIntent {
1801 version: PACK_INSTALL_INTENT_VERSION,
1802 install_id: install_id.into(),
1803 pack_name: name.clone(),
1804 phase: PackInstallPhase::PackPublished,
1805 created_unix: 1,
1806 };
1807 write_intent(&packs, &intent).unwrap();
1808
1809 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1810 assert_eq!(report.completed, 1);
1811 assert!(dst_idx_path(&packs, &name).exists());
1812 }
1813
1814 #[test]
1815 fn relocated_repo_recovery_uses_new_packs_dir() {
1816 let root = tempfile::tempdir().unwrap();
1818 let packs = root.path().join("old").join("packs");
1819 create_dir_all_durable(&packs).unwrap();
1820 let install_id = "reloc1";
1821 let name = pack_id("22");
1822 create_dir_all_durable(&staging_dir(&packs, install_id)).unwrap();
1823 write_file_atomic(&staging_pack_path(&packs, install_id), b"p").unwrap();
1824 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
1825 publish_file_durable(
1826 &staging_pack_path(&packs, install_id),
1827 &dst_pack_path(&packs, &name),
1828 )
1829 .unwrap();
1830 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
1832 let intent = PackInstallIntent {
1833 version: 2,
1834 install_id: install_id.into(),
1835 pack_name: name.clone(),
1836 phase: PackInstallPhase::PackPublished,
1837 created_unix: 1,
1838 };
1839 write_intent(&packs, &intent).unwrap();
1840
1841 let new_packs = root.path().join("new").join("packs");
1843 create_dir_all_durable(new_packs.parent().unwrap()).unwrap();
1844 fs::rename(&packs, &new_packs).unwrap();
1845
1846 let report = recover_pack_install_intents(&new_packs).unwrap();
1847 assert_eq!(report.completed, 1);
1848 assert!(dst_idx_path(&new_packs, &name).exists());
1849 }
1850
1851 #[test]
1852 fn concurrent_same_pack_installs_converge() {
1853 use std::thread;
1854
1855 let root = tempfile::tempdir().unwrap();
1856 let packs = root.path().join("packs");
1857 create_dir_all_durable(&packs).unwrap();
1858 let pack_bytes = b"same-pack-concurrent-body";
1859 let idx_bytes = empty_idx_bytes();
1860 let expected = format!("{}", blake3::hash(pack_bytes).to_hex());
1861
1862 let packs1 = packs.clone();
1863 let packs2 = packs.clone();
1864 let idx1 = idx_bytes.clone();
1865 let idx2 = idx_bytes.clone();
1866 let t1 = thread::spawn(move || {
1867 install_pack_bytes_journaled(&packs1, pack_bytes, &idx1).unwrap()
1868 });
1869 let t2 = thread::spawn(move || {
1870 install_pack_bytes_journaled(&packs2, pack_bytes, &idx2).unwrap()
1871 });
1872 let n1 = t1.join().unwrap();
1873 let n2 = t2.join().unwrap();
1874 assert_eq!(n1, expected);
1875 assert_eq!(n2, expected);
1876 assert!(existing_pair_matches_pack_name(&packs, &expected).unwrap());
1877 }
1878
1879 #[test]
1880 fn concurrent_many_distinct_pack_installs() {
1881 use std::thread;
1882
1883 let root = tempfile::tempdir().unwrap();
1885 let packs = root.path().join("packs");
1886 create_dir_all_durable(&packs).unwrap();
1887 let idx_bytes = empty_idx_bytes();
1888
1889 let mut handles = Vec::new();
1890 for i in 0..8u8 {
1891 let packs = packs.clone();
1892 let idx_bytes = idx_bytes.clone();
1893 handles.push(thread::spawn(move || {
1894 let pack_bytes = format!("many-pack-body-{i}").into_bytes();
1895 install_pack_bytes_journaled(&packs, &pack_bytes, &idx_bytes).unwrap()
1896 }));
1897 }
1898 let mut names = Vec::new();
1899 for h in handles {
1900 names.push(h.join().unwrap());
1901 }
1902 names.sort();
1903 names.dedup();
1904 assert_eq!(names.len(), 8, "expected 8 distinct pack names");
1905 for name in &names {
1906 assert!(existing_pair_matches_pack_name(&packs, name).unwrap());
1907 }
1908 }
1909
1910 #[test]
1911 fn far_future_created_unix_expires_immediately_under_ttl() {
1912 let root = tempfile::tempdir().unwrap();
1913 let packs = root.path().join("packs");
1914 create_dir_all_durable(&packs).unwrap();
1915 let install_id = "far-future";
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("aa"));
1921 intent.created_unix = unix_now().saturating_add(INTENT_CLOCK_SKEW_TOLERANCE_SECS + 10_000);
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.aborted, 1, "far-future must expire: {report:?}");
1927 assert!(!intent_path(&packs, install_id).exists());
1928 }
1929
1930 #[test]
1931 fn mild_clock_skew_does_not_expire_fresh_intent() {
1932 let root = tempfile::tempdir().unwrap();
1933 let packs = root.path().join("packs");
1934 create_dir_all_durable(&packs).unwrap();
1935 let install_id = "mild-skew";
1936 create_dir_all_durable(&staging_dir(&packs, install_id)).unwrap();
1937 write_file_atomic(&staging_pack_path(&packs, install_id), b"p").unwrap();
1938 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
1939
1940 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("bb"));
1941 intent.created_unix = unix_now().saturating_add(INTENT_CLOCK_SKEW_TOLERANCE_SECS / 2);
1943 write_intent(&packs, &intent).unwrap();
1944
1945 let report = recover_pack_install_intents_with_ttl(&packs, Some(86_400)).unwrap();
1946 assert_eq!(report.skipped_in_progress, 1, "mild skew: {report:?}");
1947 assert_eq!(report.aborted, 0);
1948 assert!(intent_path(&packs, install_id).exists());
1949 }
1950
1951 #[test]
1952 fn fault_after_intent_prepared_is_recoverable() {
1953 let root = tempfile::tempdir().unwrap();
1954 let packs = root.path().join("packs");
1955 create_dir_all_durable(&packs).unwrap();
1956
1957 let pack_bytes = b"fault-inject-pack-body";
1958 let idx_bytes = empty_idx_bytes();
1959 let expected = format!("{}", blake3::hash(pack_bytes).to_hex());
1960
1961 let before_err = pack_install_metrics_snapshot().installs_err;
1962 let err = fault_inject::with_fault_points(&["pack_install_after_intent_prepared"], || {
1963 install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes)
1964 })
1965 .expect_err("fault should fire");
1966 assert!(
1967 err.to_string()
1968 .contains("pack_install_after_intent_prepared"),
1969 "err={err}"
1970 );
1971 assert!(pack_install_metrics_snapshot().installs_err >= before_err);
1973
1974 assert_eq!(intent_count_json(&packs), 1);
1976 assert!(!dst_pack_path(&packs, &expected).exists());
1977
1978 for entry in fs::read_dir(intent_root(&packs)).unwrap().flatten() {
1980 let p = entry.path();
1981 if p.extension().and_then(|x| x.to_str()) != Some("json") {
1982 continue;
1983 }
1984 let mut intent: PackInstallIntent =
1985 serde_json::from_slice(&fs::read(&p).unwrap()).unwrap();
1986 intent.created_unix = 1;
1987 write_intent(&packs, &intent).unwrap();
1988 }
1989 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
1990 assert_eq!(report.aborted, 1, "report={report:?}");
1991 assert_eq!(intent_count_json(&packs), 0);
1992
1993 let name = install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes).unwrap();
1994 assert_eq!(name, expected);
1995 assert!(existing_pair_matches_pack_name(&packs, &expected).unwrap());
1996 }
1997
1998 #[test]
1999 fn fault_after_publish_pack_recovers_to_complete() {
2000 let root = tempfile::tempdir().unwrap();
2001 let packs = root.path().join("packs");
2002 create_dir_all_durable(&packs).unwrap();
2003
2004 let pack_bytes = b"fault-after-pack-publish-body";
2005 let idx_bytes = empty_idx_bytes();
2006 let expected = format!("{}", blake3::hash(pack_bytes).to_hex());
2007
2008 let err = fault_inject::with_fault_points(&["pack_install_after_publish_pack"], || {
2009 install_pack_bytes_journaled(&packs, pack_bytes, &idx_bytes)
2010 })
2011 .expect_err("fault should fire after pack publish");
2012 assert!(err.to_string().contains("pack_install_after_publish_pack"));
2013
2014 assert!(dst_pack_path(&packs, &expected).exists());
2017 assert!(!dst_idx_path(&packs, &expected).exists());
2018
2019 let report = recover_pack_install_intents(&packs).unwrap();
2020 assert_eq!(report.completed, 1, "report={report:?}");
2021 assert!(dst_idx_path(&packs, &expected).exists());
2022 assert!(existing_pair_matches_pack_name(&packs, &expected).unwrap());
2023 }
2024
2025 fn intent_count_json(packs: &Path) -> usize {
2026 let dir = intent_root(packs);
2027 if !dir.exists() {
2028 return 0;
2029 }
2030 fs::read_dir(dir)
2031 .unwrap()
2032 .filter(|e| {
2033 e.as_ref()
2034 .map(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json"))
2035 .unwrap_or(false)
2036 })
2037 .count()
2038 }
2039
2040 #[test]
2041 fn metrics_snapshot_tracks_install_and_recover() {
2042 let before = pack_install_metrics_snapshot();
2044 let root = tempfile::tempdir().unwrap();
2045 let packs = root.path().join("packs");
2046 create_dir_all_durable(&packs).unwrap();
2047 let _ = install_pack_bytes_journaled(&packs, b"metrics-pack", &empty_idx_bytes()).unwrap();
2048 let after_install = pack_install_metrics_snapshot();
2049 assert!(after_install.installs_ok > before.installs_ok);
2050
2051 let install_id = "metrics-abort";
2053 create_dir_all_durable(&staging_dir(&packs, install_id)).unwrap();
2054 write_file_atomic(&staging_pack_path(&packs, install_id), b"p").unwrap();
2055 write_file_atomic(&staging_idx_path(&packs, install_id), b"i").unwrap();
2056 let mut intent = PackInstallIntent::new(install_id.into(), pack_id("cc"));
2057 intent.created_unix = 1;
2058 write_intent(&packs, &intent).unwrap();
2059 let before_abort = pack_install_metrics_snapshot();
2060 let report = recover_pack_install_intents_with_ttl(&packs, Some(1)).unwrap();
2061 assert_eq!(report.aborted, 1);
2062 let after_abort = pack_install_metrics_snapshot();
2063 assert!(after_abort.recover_aborted > before_abort.recover_aborted);
2064 }
2065
2066 #[cfg(unix)]
2067 #[test]
2068 fn assert_under_packs_rejects_staging_symlink_escape() {
2069 use std::os::unix::fs::symlink;
2070
2071 let root = tempfile::tempdir().unwrap();
2072 let packs = root.path().join("packs");
2073 create_dir_all_durable(&packs).unwrap();
2074 let outside = root.path().join("outside");
2075 create_dir_all_durable(&outside).unwrap();
2076
2077 symlink(&outside, packs.join(STAGING_DIR_NAME)).unwrap();
2079
2080 let stage = staging_dir(&packs, "id1");
2081 let err = assert_under_packs(&packs, &stage).unwrap_err();
2082 assert!(
2083 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2084 "err={err}"
2085 );
2086
2087 let err = ensure_journal_layout_safe(&packs).unwrap_err();
2089 assert!(
2090 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2091 "err={err}"
2092 );
2093 let err = install_pack_bytes_journaled(&packs, b"x", &empty_idx_bytes()).unwrap_err();
2094 assert!(
2095 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2096 "err={err}"
2097 );
2098 }
2099
2100 #[cfg(unix)]
2101 #[test]
2102 fn assert_under_packs_rejects_intent_root_symlink_escape() {
2103 use std::os::unix::fs::symlink;
2104
2105 let root = tempfile::tempdir().unwrap();
2106 let packs = root.path().join("packs");
2107 create_dir_all_durable(&packs).unwrap();
2108 let outside = root.path().join("outside-intent");
2109 create_dir_all_durable(&outside).unwrap();
2110 symlink(&outside, packs.join(INTENT_DIR_NAME)).unwrap();
2111
2112 let err = ensure_journal_layout_safe(&packs).unwrap_err();
2113 assert!(
2114 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2115 "err={err}"
2116 );
2117 }
2118
2119 #[cfg(unix)]
2120 #[test]
2121 fn assert_under_packs_rejects_pack_locks_symlink_escape() {
2122 use std::os::unix::fs::symlink;
2123
2124 let root = tempfile::tempdir().unwrap();
2125 let packs = root.path().join("packs");
2126 create_dir_all_durable(&packs).unwrap();
2127 let outside = root.path().join("outside-locks");
2128 create_dir_all_durable(&outside).unwrap();
2129 symlink(&outside, packs.join(PACK_LOCKS_DIR_NAME)).unwrap();
2130
2131 let err = ensure_journal_layout_safe(&packs).unwrap_err();
2132 assert!(
2133 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2134 "err={err}"
2135 );
2136 }
2137
2138 #[cfg(unix)]
2139 #[test]
2140 fn assert_under_packs_rejects_destination_file_symlink_escape() {
2141 use std::os::unix::fs::symlink;
2142
2143 let root = tempfile::tempdir().unwrap();
2144 let packs = root.path().join("packs");
2145 create_dir_all_durable(&packs).unwrap();
2146 let outside = root.path().join("evil.pack");
2147 fs::write(&outside, b"evil").unwrap();
2148
2149 let name = pack_id("symlink-dst");
2150 let dst = dst_pack_path(&packs, &name);
2151 symlink(&outside, &dst).unwrap();
2152
2153 let err = assert_under_packs(&packs, &dst).unwrap_err();
2154 assert!(
2155 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2156 "err={err}"
2157 );
2158 let pair = existing_pair_matches_pack_name(&packs, &name);
2160 assert!(pair.as_ref().map(|v| !v).unwrap_or(true), "pair={pair:?}");
2161 }
2162
2163 #[cfg(unix)]
2164 #[test]
2165 fn assert_under_packs_rejects_install_id_staging_symlink() {
2166 use std::os::unix::fs::symlink;
2167
2168 let root = tempfile::tempdir().unwrap();
2169 let packs = root.path().join("packs");
2170 create_dir_all_durable(&packs).unwrap();
2171 create_dir_all_durable(&staging_root(&packs)).unwrap();
2172 let outside = root.path().join("outside-stage-id");
2173 create_dir_all_durable(&outside).unwrap();
2174 let install_id = "symlink-stage-id";
2175 symlink(&outside, staging_dir(&packs, install_id)).unwrap();
2176
2177 let pack_path = staging_pack_path(&packs, install_id);
2178 let err = assert_under_packs(&packs, &pack_path).unwrap_err();
2179 assert!(
2180 err.to_string().contains("escapes") || err.to_string().contains("symlink"),
2181 "err={err}"
2182 );
2183 }
2184
2185 #[test]
2186 fn assert_under_packs_accepts_normal_reconstructed_paths() {
2187 let root = tempfile::tempdir().unwrap();
2188 let packs = root.path().join("packs");
2189 create_dir_all_durable(&packs).unwrap();
2190 let name = pack_id("normal");
2191 let install_id = "normal-id";
2192 assert_under_packs(&packs, &dst_pack_path(&packs, &name)).unwrap();
2193 assert_under_packs(&packs, &staging_pack_path(&packs, install_id)).unwrap();
2194 assert_under_packs(&packs, &intent_path(&packs, install_id)).unwrap();
2195 }
2196}