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.contains(&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 {
if cfg!(target_os = "macos") {
let mut c = Command::new("osascript");
c.arg("-e")
.arg("on run argv")
.arg("-e")
.arg("display notification (item 2 of argv) with title (item 1 of argv)")
.arg("-e")
.arg("end run")
.arg("--")
.arg(one_line(title, 160))
.arg(one_line(body, 300));
return matches!(c.status(), Ok(s) if s.success());
}
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 new_decisions() -> Vec<(String, String, String)> {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&crate::ui::buddies_json()) else {
return Vec::new();
};
let announced = load_announced();
let mut out = Vec::new();
if let Some(reqs) = v.get("requests").and_then(|x| x.as_array()) {
for r in reqs {
let (Some(k), Some(label)) = (
r.get("key").and_then(|x| x.as_str()),
r.get("label").and_then(|x| x.as_str()),
) else {
continue;
};
let id = format!("req:{k}");
if announced.iter().any(|a| a == &id) {
continue;
}
let n = r.get("bundles").and_then(|x| x.as_u64()).unwrap_or(0);
let what = if n == 1 {
"a bundle".to_string()
} else {
format!("{n} bundles")
};
out.push((
id,
"gage: connection request".to_string(),
format!(
"{} sent you {what} and is not on your list. Open Gage to decide.",
one_line(label, 40)
),
));
}
}
if let Some(buds) = v.get("buddies").and_then(|x| x.as_array()) {
for b in buds {
if b.get("confirmed").and_then(|x| x.as_bool()) != Some(false) {
continue;
}
if b.get("stale").and_then(|x| x.as_bool()) == Some(true) {
continue;
}
let (Some(k), Some(name)) = (
b.get("key").and_then(|x| x.as_str()),
b.get("name").and_then(|x| x.as_str()),
) else {
continue;
};
let id = format!("unconf:{k}");
if announced.iter().any(|a| a == &id) {
continue;
}
out.push((
id,
"gage: finish connecting".to_string(),
format!(
"{} is on your list but not confirmed. Open Gage and compare the words to finish.",
one_line(name, 40)
),
));
}
}
out
}
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}");
}
}