amont-fleet 1.4.0

The amont fleet dashboard: see and repair hook coverage across many repositories
//! Classifying an installed hook file against what it should be.
//!
//! The trap here is symmetrical and both halves are silent:
//!
//! - Compare an installed shim against the RAW tracked template and every one
//!   of the 96 repos reports as drifted, because installation deliberately
//!   substitutes the binary path. A tool that cries wolf on the whole fleet is
//!   a tool nobody reads.
//! - Compare too loosely — say, on length or on one marker line — and genuine
//!   drift is never seen, which is worse, because the report still looks calm.
//!
//! So classification is exact: recover the baked path from the installed file,
//! re-render the template with it, and require byte equality. Recovery IS the
//! comparison; there is no fuzzy middle.
//!
//! Note the placeholder appears three times in the template, one of them inside
//! a comment, because `make install` seds globally. Any comparison that assumed
//! a single substitution would misclassify every baked shim.

use std::path::{Path, PathBuf};

use serde::Serialize;

/// The shim, the placeholder and the hook names come from the RUNTIME, which is
/// what installs them.
///
/// They used to be a second `include_str!` and a second copy of the constants
/// here. That was survivable while nothing else baked shims, and stopped being
/// so the moment `amont install` existed: drift detection compares a repo's
/// shim against `render(path)`, so if the installer and the dashboard disagreed
/// by one byte, every correctly installed repo in the fleet would report as
/// drifted. Re-exported rather than re-declared so they cannot.
pub use amont_runtime::install::{DISPATCHERS, PLACEHOLDER, SHIM as TEMPLATE};

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum ShimState {
    /// Byte-identical to the template rendered with `baked`.
    Ok {
        baked: String,
    },
    /// Present, readable text, but not the template under any substitution.
    Drifted,
    Missing,
    /// A symlink, pointing anywhere — including at one of our own shims.
    ///
    /// This used to collapse into `Ok`/`Drifted`, because `read_to_string`
    /// follows a link and reports the TARGET's bytes. A dispatcher that is a
    /// link to a tracked file in the working tree therefore read as a perfectly
    /// healthy shim, and `fix --apply` then wrote through it — `fs::write`
    /// follows links too — and rewrote the tracked file. That is the verified
    /// incident `hookfile` exists to end, and this variant is how the dashboard
    /// can see it before the write does.
    Symlink {
        target: Option<PathBuf>,
    },
    /// It exists and we could not establish what it is: not valid UTF-8 (a
    /// compiled hook), unreadable, a directory, a device, a hard link with other
    /// names. Every one of these used to become `Drifted` or `Missing` — the
    /// latter being the dangerous one, since "missing" is what makes `fix`
    /// decide to WRITE.
    Unreadable {
        why: String,
    },
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "bake", rename_all = "snake_case")]
pub enum BakeState {
    /// Points at the binary we would install.
    Current,
    /// Points somewhere else — the GUI-client failure mode, since a hook
    /// launched without an interactive PATH resolves nothing.
    Stale { path: String },
    /// The placeholder survived; resolution falls through to $HOME/.local/bin.
    Unbaked,
    /// The shims disagree with each other about where the binary is.
    Mixed,
    /// Nothing installed to have an opinion about.
    None,
}

pub fn render(binary: &str) -> String {
    amont_runtime::install::bake(TEMPLATE, binary)
}

/// Recover the substituted path, or `None` if this file is not the template
/// under ANY substitution.
///
/// Solved from the template's literal segments rather than from a marker line.
/// An earlier version anchored on `BIN="…"` and silently recovered the WRONG
/// value: the template assigns `BIN="$GIT_HOOKS_BIN"` on an earlier line for
/// the escape hatch, so the first match was never the baked path. Every
/// correctly installed shim in the fleet would have reported as drifted.
///
/// Splitting on the placeholder cannot pick the wrong line, because the segments
/// around each occurrence are fixed text. The candidate is still only accepted
/// once re-rendering reproduces the file byte for byte, so a wrong guess is
/// rejected rather than believed.
pub fn recover_baked(installed: &str) -> Option<String> {
    let head = TEMPLATE.split(PLACEHOLDER).next()?;
    let rest = installed.strip_prefix(head)?;
    // The text that follows the first placeholder is fixed; whatever precedes
    // it is the substitution.
    let tail = TEMPLATE.split(PLACEHOLDER).nth(1)?;
    let end = if tail.is_empty() {
        rest.len()
    } else {
        rest.find(tail)?
    };
    let candidate = rest[..end].to_string();
    (render(&candidate) == installed).then_some(candidate)
}

/// What is installed at one dispatcher path.
///
/// Two questions, asked in this order and by two different owners:
///
/// 1. WHAT IS THERE — a link, a directory, a binary, an unreadable file, a
///    regular readable file. [`amont_runtime::hookfile::classify`] owns that
///    one, because it is the same question `install` and `uninstall` ask and
///    three separate one-liners used to answer it differently. It never follows
///    a link and never guesses.
/// 2. IS IT OUR TEMPLATE, byte for byte — which only [`recover_baked`] can
///    answer, since `hookfile` knows about our marker but not about the exact
///    substitution.
///
/// The body used to be `read_to_string(path)` with `Err` ⇒ `Missing`, which
/// answered neither question honestly: a compiled hook, a directory and a
/// permissions error all read as "nothing installed", and "nothing installed"
/// is what makes `fix` decide to write one.
pub fn classify(path: &Path) -> ShimState {
    use amont_runtime::hookfile::{ForeignWhy, HookFile};
    match amont_runtime::hookfile::classify(path) {
        HookFile::Absent => ShimState::Missing,
        HookFile::Symlink { target } => ShimState::Symlink { target },
        HookFile::NotARegularFile => ShimState::Unreadable {
            why: "not a regular file (a directory, a fifo, a device)".to_string(),
        },
        HookFile::Unknown { why } => ShimState::Unreadable { why },
        HookFile::Foreign(ForeignWhy::HandWritten) | HookFile::Ours => {
            // A regular, readable, UTF-8 file: now the byte-exact question.
            match std::fs::read_to_string(path)
                .ok()
                .and_then(|c| recover_baked(&c))
            {
                Some(baked) => ShimState::Ok { baked },
                None => ShimState::Drifted,
            }
        }
        HookFile::Foreign(why) => ShimState::Unreadable {
            why: why.describe(),
        },
    }
}

/// Collapse the four shims' baked paths into one verdict.
pub fn bake_state(shims: &[ShimState], installed_binary: &str) -> BakeState {
    let mut baked: Vec<&str> = shims
        .iter()
        .filter_map(|s| match s {
            ShimState::Ok { baked } => Some(baked.as_str()),
            _ => None,
        })
        .collect();
    baked.sort_unstable();
    baked.dedup();

    match baked.as_slice() {
        [] => BakeState::None,
        [one] if *one == PLACEHOLDER => BakeState::Unbaked,
        [one] if *one == installed_binary => BakeState::Current,
        [one] => BakeState::Stale {
            path: (*one).to_string(),
        },
        _ => BakeState::Mixed,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn templates_are_still_one_blob() {
        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
        for name in DISPATCHERS {
            let p = std::path::Path::new(dir).join(name);
            let got = std::fs::read_to_string(&p).expect("template");
            assert_eq!(
                got, TEMPLATE,
                "{name} differs from the embedded template; embedding one blob \
                 is no longer valid and classification would be wrong for it"
            );
        }
    }

    /// EVERY occurrence, whatever the count. It used to assert exactly three,
    /// which is a fact about today's shim text rather than about the renderer —
    /// rewording a comment that mentioned the token broke it while nothing was
    /// wrong. What matters is that the installer and this agree, which the
    /// shared constants now make structural, and that none survives.
    #[test]
    fn every_occurrence_is_substituted() {
        let expected = TEMPLATE.matches(PLACEHOLDER).count();
        assert!(expected > 0, "the template lost its placeholder entirely");
        let out = render("/opt/amont");
        assert!(!out.contains(PLACEHOLDER), "a placeholder survived");
        assert_eq!(out.matches("/opt/amont").count(), expected);
    }

    /// The dashboard and the installer must produce the same bytes, or a
    /// correctly installed repo reads as drifted.
    #[test]
    fn the_dashboard_renders_what_the_installer_writes() {
        assert_eq!(
            render("/opt/amont"),
            amont_runtime::install::bake(amont_runtime::install::SHIM, "/opt/amont")
        );
    }

    #[test]
    fn a_correctly_baked_shim_is_not_drift() {
        let installed = render("/Users/me/.local/bin/amont");
        assert_eq!(
            recover_baked(&installed).as_deref(),
            Some("/Users/me/.local/bin/amont"),
            "the whole fleet would read as drifted"
        );
    }

    #[test]
    fn an_unbaked_shim_recovers_the_placeholder() {
        assert_eq!(recover_baked(TEMPLATE).as_deref(), Some(PLACEHOLDER));
    }

    /// The other half of the trap: real drift must not be waved through.
    #[test]
    fn genuine_drift_is_detected() {
        let mut edited = render("/opt/amont");
        edited.push_str("\n# someone added this\n");
        assert_eq!(recover_baked(&edited), None);

        let hand_written = "#!/bin/sh\nBIN=\"/opt/amont\"\nexec \"$BIN\" \"$@\"\n";
        assert_eq!(
            recover_baked(hand_written),
            None,
            "a plausible-looking file that is not our template must not pass"
        );
    }

    /// A candidate that reproduces the file is required — finding `BIN="…"` is
    /// not enough on its own.
    #[test]
    fn the_anchor_alone_does_not_satisfy_it() {
        let faked = render("/opt/a").replace("exec", "# exec");
        assert!(faked.contains("BAKED=\"/opt/a\""));
        assert_eq!(recover_baked(&faked), None);
    }

    fn tmpdir(name: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("fleet-shim-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).expect("mkdir");
        d
    }

    /// The verified incident, at the level of one classification. A dispatcher
    /// that is a LINK to a healthy shim used to read as `Ok` — `read_to_string`
    /// follows links — so nothing in the dashboard or the plan could see that a
    /// write here lands somewhere else entirely.
    #[cfg(unix)]
    #[test]
    fn a_symlinked_dispatcher_is_a_symlink_not_a_healthy_shim() {
        let d = tmpdir("symlink");
        let real = d.join("shared-pre-commit");
        std::fs::write(&real, render("/bin/gh")).unwrap();
        let link = d.join("pre-commit");
        std::os::unix::fs::symlink(&real, &link).unwrap();

        assert_eq!(
            classify(&link),
            ShimState::Symlink {
                target: Some(real.clone())
            },
            "a link to a perfect shim is still a link"
        );
        let _ = std::fs::remove_dir_all(&d);
    }

    /// A compiled hook is not "missing", and the difference matters: `Missing`
    /// is the state that makes `fix` decide to WRITE one.
    #[test]
    fn a_binary_hook_is_unreadable_not_missing() {
        let d = tmpdir("binary");
        let p = d.join("pre-commit");
        std::fs::write(&p, [0x7f, b'E', b'L', b'F', 0x02, 0x01, 0xff, 0xfe]).unwrap();
        assert!(
            matches!(classify(&p), ShimState::Unreadable { .. }),
            "classified as {:?}",
            classify(&p)
        );
        let _ = std::fs::remove_dir_all(&d);
    }

    fn ok(p: &str) -> ShimState {
        ShimState::Ok { baked: p.into() }
    }

    #[test]
    fn bake_states() {
        assert_eq!(bake_state(&[ok("/bin/gh")], "/bin/gh"), BakeState::Current);
        assert_eq!(
            bake_state(&[ok("/old/gh")], "/bin/gh"),
            BakeState::Stale {
                path: "/old/gh".into()
            }
        );
        assert_eq!(
            bake_state(&[ok(PLACEHOLDER)], "/bin/gh"),
            BakeState::Unbaked
        );
        assert_eq!(
            bake_state(&[ok("/a"), ok("/b")], "/bin/gh"),
            BakeState::Mixed
        );
        assert_eq!(
            bake_state(&[ShimState::Missing], "/bin/gh"),
            BakeState::None
        );
        // Four identical shims are Current, not Mixed.
        assert_eq!(
            bake_state(
                &[ok("/bin/gh"), ok("/bin/gh"), ok("/bin/gh"), ok("/bin/gh")],
                "/bin/gh"
            ),
            BakeState::Current
        );
    }
}