use crate::gitcmd;
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Presence {
pub role: String,
#[serde(default)]
pub host: Option<String>,
pub last_seen: String,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub poll_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
}
fn presence_ref(role: &str) -> String {
format!("refs/presence/{role}")
}
pub fn publish(root: &Path, p: &Presence) -> Result<()> {
let json = serde_json::to_string(p)?;
let blob = must(gitcmd::output_stdin(root, &["hash-object", "-w", "--stdin"], &json)?, "hash-object")?;
let tree_input = format!("100644 blob {blob}\tpresence.json\n");
let tree = must(gitcmd::output_stdin(root, &["mktree"], &tree_input)?, "mktree")?;
let msg = format!("presence {} {}", p.role, p.last_seen);
let commit = if let Some(key) = crate::config::signing_key(root) {
let keygen = crate::ssh_keygen_path();
must(
gitcmd::output(
root,
&[
"-c", "gpg.format=ssh",
"-c", &format!("user.signingkey={}", key.display()),
"-c", &format!("gpg.ssh.program={keygen}"),
"commit-tree", &tree, "-S", "-m", &msg,
],
)?,
"commit-tree -S",
)?
} else {
must(gitcmd::output(root, &["commit-tree", &tree, "-m", &msg])?, "commit-tree")?
};
let refname = presence_ref(&p.role);
gitcmd::check(root, &["update-ref", &refname, &commit])?;
let refspec = format!("{commit}:{refname}");
let push = gitcmd::output(root, &["push", "--force", "origin", &refspec])?;
if !push.status.success() {
return Err(anyhow!("presence push: {}", String::from_utf8_lossy(&push.stderr).trim()));
}
Ok(())
}
fn must(o: std::process::Output, what: &str) -> Result<String> {
if o.status.success() {
Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
Err(anyhow!("{what}: {}", String::from_utf8_lossy(&o.stderr).trim()))
}
}
pub fn load_all(root: &Path, fetch: bool) -> Vec<Presence> {
if fetch {
let _ = gitcmd::output(root, &["fetch", "-q", "origin", "+refs/presence/*:refs/presence/*"]);
}
let mut out = Vec::new();
let Ok(o) = gitcmd::output(root, &["for-each-ref", "--format=%(refname)", "refs/presence/"]) else {
return out;
};
for refname in String::from_utf8_lossy(&o.stdout).lines() {
let spec = format!("{}:presence.json", refname.trim());
if let Ok(b) = gitcmd::output(root, &["cat-file", "blob", &spec]) {
if b.status.success() {
if let Ok(p) = serde_json::from_slice::<Presence>(&b.stdout) {
out.push(p);
}
}
}
}
out
}
pub struct Beat {
pub p: Presence,
pub trust: BeatTrust,
}
#[derive(PartialEq, Eq, Debug, Clone)]
pub enum BeatTrust {
Signed,
Unsigned,
Untrusted(String),
}
impl BeatTrust {
pub fn ok(&self) -> bool {
!matches!(self, BeatTrust::Untrusted(_))
}
pub fn is_signed(&self) -> bool {
matches!(self, BeatTrust::Signed)
}
}
fn hwm_path(hub_key: &str) -> Option<std::path::PathBuf> {
let safe: String = hub_key.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
Some(crate::config::home().ok()?.join(".confer").join("presence_hwm").join(format!("{safe}.json")))
}
#[derive(serde::Serialize, serde::Deserialize, Default, Clone)]
struct Hwm {
#[serde(default)]
last_seen: String,
#[serde(default)]
ever_signed: bool,
}
fn load_hwm(hub_key: &str) -> std::collections::HashMap<String, Hwm> {
hwm_path(hub_key)
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_hwm(hub_key: &str, updates: &std::collections::HashMap<String, Hwm>) {
let Some(p) = hwm_path(hub_key) else { return };
if let Some(d) = p.parent() {
let _ = std::fs::create_dir_all(d);
}
let _guard = crate::config::state_lock(&p.with_extension("lock"));
let mut cur = load_hwm(hub_key);
for (role, u) in updates {
let e = cur.entry(role.clone()).or_default();
e.ever_signed |= u.ever_signed;
let newer = e.last_seen.is_empty()
|| matches!(
(DateTime::parse_from_rfc3339(&u.last_seen), DateTime::parse_from_rfc3339(&e.last_seen)),
(Ok(a), Ok(b)) if a > b
);
if newer && !u.last_seen.is_empty() {
e.last_seen = u.last_seen.clone();
}
}
if let Ok(s) = serde_json::to_string(&cur) {
let tmp = p.with_extension(format!("tmp.{}", std::process::id()));
if std::fs::write(&tmp, s).is_ok() {
let _ = std::fs::rename(&tmp, &p);
}
}
}
pub fn load_verified(root: &Path, hub_key: &str, ros: &crate::roster::Roster, fetch: bool) -> Vec<Beat> {
if fetch {
let _ = gitcmd::output(root, &["fetch", "-q", "origin", "+refs/presence/*:refs/presence/*"]);
}
let mut cache = crate::verify::Cache::default();
let mut hwm = load_hwm(hub_key);
let mut hwm_dirty = false;
let mut out = Vec::new();
let now = Utc::now();
let skew = chrono::Duration::seconds(300);
let Ok(o) = gitcmd::output(root, &["for-each-ref", "--format=%(objectname) %(refname)", "refs/presence/"]) else {
return out;
};
for line in String::from_utf8_lossy(&o.stdout).lines() {
let Some((sha, refname)) = line.trim().split_once(' ') else { continue };
let refname = refname.trim();
let spec = format!("{refname}:presence.json");
let Ok(b) = gitcmd::output(root, &["cat-file", "blob", &spec]) else { continue };
if !b.status.success() {
continue;
}
let Ok(p) = serde_json::from_slice::<Presence>(&b.stdout) else { continue };
if refname != format!("refs/presence/{}", p.role) {
continue;
}
let prev = hwm.get(&p.role).cloned().unwrap_or_default();
let ct = crate::verify::commit_trust(root, hub_key, ros, &mut cache, &p.role, sha);
let signed = matches!(
ct,
crate::verify::Trust::Verified { .. } | crate::verify::Trust::FirstSight { .. }
);
let must_sign = prev.ever_signed || crate::keyring::confirmed(hub_key, &p.role);
let mut trust = match ct {
crate::verify::Trust::Verified { .. } | crate::verify::Trust::FirstSight { .. } => BeatTrust::Signed,
crate::verify::Trust::Mismatch { .. } => BeatTrust::Untrusted("presence key mismatch vs the pin".into()),
crate::verify::Trust::Unverified { .. } if must_sign => {
BeatTrust::Untrusted("heartbeat not signed, but this role is expected to sign (downgrade/forge?)".into())
}
crate::verify::Trust::Unverified { .. } => BeatTrust::Unsigned,
};
let cur_ts = DateTime::parse_from_rfc3339(&p.last_seen).ok().map(|t| t.with_timezone(&Utc));
let prev_ts = DateTime::parse_from_rfc3339(&prev.last_seen).ok().map(|t| t.with_timezone(&Utc));
if matches!(trust, BeatTrust::Signed) {
if let (Some(cur), Some(old)) = (cur_ts, prev_ts) {
if cur < old {
trust = BeatTrust::Untrusted("last_seen went backwards (replay/suppression?)".into());
}
}
}
let mut rec = prev;
if signed && !rec.ever_signed {
rec.ever_signed = true;
hwm_dirty = true;
}
if matches!(trust, BeatTrust::Signed) {
if let Some(cur) = cur_ts {
let not_future = cur <= now + skew;
let newer = prev_ts.map(|old| cur > old).unwrap_or(true);
if not_future && newer {
rec.last_seen = p.last_seen.clone();
hwm_dirty = true;
}
}
}
hwm.insert(p.role.clone(), rec);
out.push(Beat { p, trust });
}
if hwm_dirty {
save_hwm(hub_key, &hwm);
}
out
}
#[derive(PartialEq, Eq, Debug)]
pub enum Live {
Up,
Stale,
Down,
}
pub fn liveness(p: &Presence, now: DateTime<Utc>) -> Live {
let Ok(seen) = DateTime::parse_from_rfc3339(&p.last_seen) else {
return Live::Down;
};
let age = (now - seen.with_timezone(&Utc)).num_seconds();
if age < 0 {
return if age > -300 { Live::Up } else { Live::Down };
}
let live_win = (p.poll_secs.max(10) * 24).max(360) as i64;
if age <= live_win {
Live::Up
} else if age <= 1800 {
Live::Stale
} else {
Live::Down
}
}
pub fn glyph(l: &Live) -> &'static str {
match l {
Live::Up => "●",
Live::Stale => "○",
Live::Down => "✕",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn p(last_seen: &str, poll: u64) -> Presence {
Presence { role: "x".into(), host: None, last_seen: last_seen.into(), cursor: None, poll_secs: poll, build: None }
}
#[test]
fn liveness_windows() {
let now = DateTime::parse_from_rfc3339("2026-07-10T12:00:00Z").unwrap().with_timezone(&Utc);
assert_eq!(liveness(&p("2026-07-10T11:59:30Z", 10), now), Live::Up);
assert_eq!(liveness(&p("2026-07-10T11:55:00Z", 10), now), Live::Up);
assert_eq!(liveness(&p("2026-07-10T11:50:00Z", 10), now), Live::Stale);
assert_eq!(liveness(&p("2026-07-10T11:15:00Z", 10), now), Live::Down);
assert_eq!(liveness(&p("not-a-date", 10), now), Live::Down);
}
#[test]
fn far_future_stamp_is_not_trusted_as_up() {
let now = DateTime::parse_from_rfc3339("2026-07-10T12:00:00Z").unwrap().with_timezone(&Utc);
assert_eq!(liveness(&p("2026-07-10T12:02:00Z", 10), now), Live::Up); assert_eq!(liveness(&p("2026-07-11T12:00:00Z", 10), now), Live::Down); }
#[test]
fn beat_trust_ok_only_when_not_untrusted() {
assert!(BeatTrust::Signed.ok());
assert!(BeatTrust::Unsigned.ok());
assert!(!BeatTrust::Untrusted("forged".into()).ok());
}
}