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