use std::path::{Path, PathBuf};
use serde::Serialize;
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 {
Ok {
baked: String,
},
Drifted,
Missing,
Symlink {
target: Option<PathBuf>,
},
Unreadable {
why: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "bake", rename_all = "snake_case")]
pub enum BakeState {
Current,
Stale { path: String },
Unbaked,
Mixed,
None,
}
pub fn render(binary: &str) -> String {
amont_runtime::install::bake(TEMPLATE, binary)
}
pub fn recover_baked(installed: &str) -> Option<String> {
let head = TEMPLATE.split(PLACEHOLDER).next()?;
let rest = installed.strip_prefix(head)?;
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)
}
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 => {
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(),
},
}
}
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"
);
}
}
#[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);
}
#[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));
}
#[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"
);
}
#[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
}
#[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);
}
#[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
);
assert_eq!(
bake_state(
&[ok("/bin/gh"), ok("/bin/gh"), ok("/bin/gh"), ok("/bin/gh")],
"/bin/gh"
),
BakeState::Current
);
}
}