Skip to main content

objects/store/fs/
pack_install_journal.rs

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