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