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