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