Skip to main content

objects/store/fs/
pack_install_journal.rs

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