use std::collections::BTreeSet;
use std::process::Command;
fn announced_path() -> std::path::PathBuf {
crate::identity::home().join("announced.json")
}
fn load_announced() -> Vec<String> {
let Ok(text) = std::fs::read_to_string(announced_path()) else {
return Vec::new();
};
if let Ok(v) = serde_json::from_str::<Vec<String>>(&text) {
return v;
}
serde_json::from_str::<BTreeSet<String>>(&text)
.map(|s| s.into_iter().collect())
.unwrap_or_default()
}
fn save_announced(v: &[String]) {
let start = v.len().saturating_sub(2000);
if let Ok(bytes) = serde_json::to_vec(&v[start..]) {
let _ = std::fs::write(announced_path(), bytes);
}
}
pub struct Pending {
pub id: String,
pub from: String,
pub msg_type: String,
pub subject: String,
pub expects_reply: bool,
pub key: String,
pub grant: Option<String>,
}
pub fn new_arrivals() -> Vec<Pending> {
let st = crate::state::LocalState::load();
let contacts = crate::trust::Contacts::load();
let authority = crate::authority::Authority::load();
let seen = load_announced();
let home = crate::identity::home();
let mut out = Vec::new();
for (_, l) in crate::routes::list(&crate::routes::pending_dir(&home)) {
let m = &l.manifest;
if !l.tampered.is_empty() || matches!(l.trust, crate::trust::Trust::Unverified) {
continue;
}
if st.is_read(&m.id) || seen.iter().any(|x| *x == m.id) {
continue;
}
let grant = authority
.lookup(
&contacts,
l.trust,
&m.from.key,
m.msg_type,
crate::authority::answers_something_i_sent(&home, m).is_some(),
)
.ok()
.map(|g| g.to_string());
out.push(Pending {
key: m.from.key.clone(),
grant,
id: m.id.clone(),
from: contacts
.name_for(&m.from.key)
.map(|s| s.to_string())
.unwrap_or_else(|| format!("\"{}\" (unlisted)", one_line(&m.from.handle, 40))),
msg_type: m.msg_type.label().to_string(),
subject: one_line(&m.subject, 120),
expects_reply: m.msg_type.expects_reply(),
});
}
out
}
pub fn mark_announced(ids: &[String]) {
let mut v = load_announced();
for i in ids {
if !v.iter().any(|x| x == i) {
v.push(i.clone());
}
}
save_announced(&v);
}
fn one_line(s: &str, max: usize) -> String {
let flat: String = s
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.filter(|c| *c != '<' && *c != '>' && *c != '&')
.collect();
let flat = flat.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() > max {
let cut: String = flat.chars().take(max).collect();
format!("{cut}…")
} else {
flat
}
}
pub fn desktop(title: &str, body: &str, urgent: bool) -> bool {
let mut c = Command::new("notify-send");
c.arg("--app-name=gage")
.arg(format!("--urgency={}", if urgent { "critical" } else { "normal" }))
.arg("--")
.arg(one_line(title, 160))
.arg(body);
matches!(c.status(), Ok(s) if s.success())
}
pub fn summarise(p: &[Pending]) -> (String, String) {
if p.len() == 1 {
let m = &p[0];
let t = format!("gage: {} from {}", m.msg_type, m.from);
let b = if m.expects_reply {
format!("{}\nawaiting your reply", m.subject)
} else {
m.subject.clone()
};
(t, b)
} else {
let needs = p.iter().filter(|x| x.expects_reply).count();
let t = format!("gage: {} new bundles", p.len());
let mut b: Vec<String> = p.iter().take(4).map(|x| format!("{} — {}", x.from, x.subject)).collect();
if p.len() > 4 {
b.push(format!("and {} more", p.len() - 4));
}
if needs > 0 {
b.push(format!("{needs} awaiting your reply"));
}
(t, b.join("\n"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn p(from: &str, ty: &str, subj: &str, expects: bool) -> Pending {
Pending {
id: "x".into(),
from: from.into(),
msg_type: ty.into(),
subject: subj.into(),
expects_reply: expects,
key: "k".into(),
grant: None,
}
}
#[test]
fn a_single_arrival_names_sender_and_type() {
let (t, b) = summarise(&[p("codex", "question", "key rotation", true)]);
assert!(t.contains("question") && t.contains("codex"), "{t}");
assert!(b.contains("awaiting your reply"), "{b}");
}
#[test]
fn many_arrivals_collapse_rather_than_spam() {
let v: Vec<Pending> = (0..7).map(|i| p("grok", "share", &format!("s{i}"), false)).collect();
let (t, b) = summarise(&v);
assert!(t.contains('7'), "{t}");
assert!(b.contains("and 3 more"), "{b}");
}
#[test]
fn sender_text_cannot_forge_extra_notification_lines() {
let forged = "innocent\nawaiting your reply\nctx: 4 new bundles";
let flat = one_line(forged, 120);
assert!(!flat.contains('\n'), "newlines survived: {flat:?}");
assert!(flat.starts_with("innocent"));
}
#[test]
fn notification_text_is_bounded_and_unmarked_up() {
let long = "x".repeat(500);
assert!(one_line(&long, 120).chars().count() <= 121);
assert_eq!(one_line("<b>bold</b> &", 120), "bbold/b amp;");
assert_eq!(one_line("a\u{1b}[2Kb\tc", 120), "a [2Kb c");
}
#[test]
fn a_notification_never_carries_payload_contents() {
let (t, b) = summarise(&[p("dana", "share", "quarterly numbers", false)]);
for s in [&t, &b] {
assert!(!s.contains("items/"), "notification referenced bundle contents: {s}");
}
}
}
pub fn run_hook(
cmd: &str,
p: &Pending,
timeout: u64,
auto_accepted: bool,
) -> Result<(), String> {
use std::process::Stdio;
let mut child = Command::new("sh")
.arg("-c")
.arg(cmd)
.env("CTX_BUNDLE_ID", &p.id)
.env("CTX_FROM", one_line(&p.from, 80))
.env("CTX_FROM_KEY", &p.key)
.env("CTX_TYPE", &p.msg_type)
.env("CTX_SUBJECT", one_line(&p.subject, 200))
.env("CTX_EXPECTS_REPLY", if p.expects_reply { "1" } else { "0" })
.env("CTX_GRANT", p.grant.clone().unwrap_or_default())
.env("CTX_AUTO_ACCEPTED", if auto_accepted { "1" } else { "0" })
.stdin(Stdio::null())
.spawn()
.map_err(|e| format!("could not start the hook: {e}"))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);
loop {
match child.try_wait() {
Ok(Some(st)) if st.success() => return Ok(()),
Ok(Some(st)) => return Err(format!("the hook exited {}", code_of(st))),
Ok(None) => {}
Err(e) => return Err(format!("could not wait for the hook: {e}")),
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(format!("the hook ran longer than {timeout}s and was killed"));
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
fn code_of(st: std::process::ExitStatus) -> String {
match st.code() {
Some(c) => c.to_string(),
None => "on a signal".to_string(),
}
}
#[cfg(test)]
mod hook_tests {
use super::*;
fn p() -> Pending {
Pending {
id: "abc123".into(),
from: "mac".into(),
msg_type: "share".into(),
subject: "one\ntwo".into(),
expects_reply: false,
key: "KEY".into(),
grant: Some("allow mac share".into()),
}
}
#[test]
fn the_hook_gets_sender_text_as_data_not_as_shell() {
let out = std::env::temp_dir().join(format!("gage-hook-{}", std::process::id()));
let _ = std::fs::remove_file(&out);
let mut hostile = p();
hostile.subject = format!("$(touch {})", out.display());
run_hook("printf '%s' \"$CTX_SUBJECT\" > /dev/null", &hostile, 10, false).unwrap();
assert!(!out.exists(), "a subject executed as shell");
}
#[test]
fn a_newline_in_the_subject_cannot_forge_a_second_line() {
let flat = one_line(&p().subject, 200);
assert!(!flat.contains('\n'));
assert_eq!(flat, "one two");
}
#[test]
fn a_hook_that_hangs_is_killed_rather_than_stalling_the_watcher() {
let err = run_hook("sleep 30", &p(), 1, false).unwrap_err();
assert!(err.contains("was killed"), "{err}");
}
#[test]
fn a_failing_hook_is_reported_with_its_code() {
let err = run_hook("exit 7", &p(), 10, false).unwrap_err();
assert!(err.contains("exited 7"), "{err}");
}
}