use crate::manifest::AgentInfo;
use crate::{bundle, collect, identity, journal, manifest, routes, scrub, state, trust};
use std::path::PathBuf;
pub fn find_manifest(id: &str) -> Option<manifest::Manifest> {
let home = identity::home();
for stage in [routes::accepted_dir(&home), routes::pending_dir(&home)] {
let d = stage.join(id);
if d.is_dir()
&& let Ok(l) = bundle::load(&d)
{
return Some(l.manifest);
}
}
None
}
pub fn all_manifests() -> Vec<manifest::Manifest> {
let home = identity::home();
if let Err(e) = routes::observe_inbox() {
eprintln!(
"gage: WARNING -- inbox not recorded ({e}); verdicts below may read milder than the truth"
);
}
let mut out = Vec::new();
for stage in [
routes::accepted_dir(&home),
routes::pending_dir(&home),
routes::sent_dir(&home),
] {
for (_, l) in routes::list(&stage) {
out.push(l.manifest);
}
}
out.sort_by(|a, b| a.id.cmp(&b.id));
out.dedup_by(|a, b| a.id == b.id);
out
}
pub struct ReplyTarget {
pub parent: String,
pub to: String,
pub msg_type: String,
pub subject: String,
}
pub fn refuse_out_in_records(
out: &std::path::Path,
why: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let home = identity::home();
let abs = if out.is_absolute() {
out.to_path_buf()
} else {
std::env::current_dir()?.join(out)
};
let mut norm = PathBuf::new();
for c in abs.components() {
match c {
std::path::Component::ParentDir => {
norm.pop();
}
std::path::Component::CurDir => {}
other => norm.push(other),
}
}
for reserved in ["inbox", "sent", "outbox"] {
if norm.starts_with(home.join(reserved)) {
return Err(format!(
"--out may not write inside {}: that is where this machine records \
what ARRIVED and what it sent. {why}",
home.join(reserved).display()
)
.into());
}
}
Ok(())
}
pub fn resolve_reply(id: &str) -> Result<ReplyTarget, Box<dyn std::error::Error>> {
let me = identity::Identity::load()?.public_b64();
let st = state::LocalState::load();
let ts = state::threads(all_manifests(), &st, &me);
let by_msg: Vec<(&state::Thread, &manifest::Manifest)> = ts
.iter()
.flat_map(|t| t.messages.iter().map(move |m| (t, m)))
.filter(|(_, m)| m.id.starts_with(id))
.collect();
let (thread, parent) = if !by_msg.is_empty() {
if by_msg.len() > 1 {
let ids: Vec<&str> = by_msg.iter().map(|(_, m)| m.id.as_str()).collect();
return Err(format!(
"{id} matches {} messages: {}. Use more characters.",
ids.len(),
ids.join(", ")
)
.into());
}
let (t, m) = by_msg[0];
(t, m.clone())
} else {
let hits: Vec<&state::Thread> = ts.iter().filter(|t| t.id.starts_with(id)).collect();
let t = match hits.len() {
0 => return Err(format!("no thread or message starting with {id}").into()),
1 => hits[0],
_ => {
let ids: Vec<&str> = hits.iter().map(|t| t.id.as_str()).collect();
return Err(
format!("{id} matches {} threads: {}", ids.len(), ids.join(", ")).into(),
);
}
};
if t.others.len() > 1 {
let contacts = trust::Contacts::load();
let who: Vec<String> = t
.others
.iter()
.map(|k| match contacts.name_any(k) {
Some(n) => format!("{n} ({}…)", k.chars().take(10).collect::<String>()),
None => format!(
"an unlisted key ({}…)",
k.chars().take(10).collect::<String>()
),
})
.collect();
let newest: Vec<String> = t
.messages
.iter()
.rev()
.filter(|m| m.from.key != me)
.take(2)
.map(|m| format!("{} from {}", m.id, m.from.handle))
.collect();
return Err(format!(
"thread {} has {} correspondents ({}), so naming the thread does not \
name a recipient. Reply to one MESSAGE instead:\n {}",
t.id,
t.others.len(),
who.join(", "),
newest.join("\n ")
)
.into());
}
let parent = t
.messages
.iter()
.rev()
.find(|m| m.from.key != me)
.ok_or_else(|| {
format!(
"every message in thread {} is your own, so there is nobody to reply \
to. Name a message id, or use gage send --reply-to.",
t.id
)
})?
.clone();
(t, parent)
};
if parent.from.key == me {
return Err(format!(
"{} is your own message, so there is nobody to reply to. Name the \
message you are answering, or use gage send --reply-to.",
parent.id
)
.into());
}
let contacts = trust::Contacts::load();
let to = match contacts.name_for(&parent.from.key) {
Some(n) => n.to_string(),
None => {
let short: String = parent.from.key.chars().take(16).collect();
return Err(match contacts.retired_record(&parent.from.key) {
Some(r) => format!(
"that message was signed with a key you retired on {} ({short}…). \
Get their current key before replying.",
r.retired_at
),
None => format!(
"the key that signed that message is not on your list ({short}…). It \
claims the name {:?}, which proves nothing. Add them first.",
parent.from.handle
),
}
.into());
}
};
let msg_type = if parent.msg_type.expects_reply() {
"answer"
} else {
"share"
}
.to_string();
let subject = if thread.subject.to_ascii_lowercase().starts_with("re:") {
thread.subject.clone()
} else {
format!("re: {}", thread.subject)
};
Ok(ReplyTarget {
parent: parent.id.clone(),
to,
msg_type,
subject,
})
}
pub struct SendArgs {
pub to: Vec<String>,
pub subject: String,
pub out: Option<PathBuf>,
pub file: Vec<PathBuf>,
pub prompt: Vec<PathBuf>,
pub decision: Vec<PathBuf>,
pub memory: Vec<PathBuf>,
pub dir: Vec<PathBuf>,
pub allow_secrets: bool,
pub reply_to: Option<String>,
pub msg_type: String,
pub allow_findings: bool,
pub lan: bool,
pub agent: Option<String>,
pub model: Option<String>,
pub record_send: bool,
pub ttl: Option<String>,
pub expires: Option<String>,
pub context: Option<String>,
}
pub fn resolve_expiry(
ttl: Option<&str>,
expires: Option<&str>,
now: i64,
) -> Result<Option<String>, String> {
match (ttl, expires) {
(Some(_), Some(_)) => Err("--ttl and --expires say the same thing two ways; pass one. \
--ttl 24h is a window from now, --expires is an instant."
.into()),
(None, None) => Ok(None),
(Some(t), None) => {
let secs = parse_duration(t)?;
let at = now
.checked_add(secs)
.filter(|at| *at <= MAX_EXPIRY)
.ok_or_else(|| too_far(t))?;
Ok(Some(crate::bundle::rfc3339(at)))
}
(None, Some(e)) => {
let at = crate::bundle::parse_rfc3339(e).ok_or_else(|| {
format!(
"--expires wants an RFC 3339 timestamp like 2026-11-09T09:00:00Z, not {:?}. \
A zone is required: a bare local time is hours away from where you meant it.",
manifest::one_line(e)
)
})?;
if at <= now {
return Err(format!(
"--expires {} is already past; the bundle would be built expired and \
its recipient could not open it.",
manifest::one_line(e)
));
}
if at > MAX_EXPIRY {
return Err(too_far_expires(e));
}
Ok(Some(crate::bundle::rfc3339(at)))
}
}
}
const MAX_EXPIRY: i64 = 253_402_300_799;
fn too_far(w: &str) -> String {
format!(
"--ttl {:?} is too far out; gage cannot write an expiry past \
9999-12-31T23:59:59Z. Pass a window a recipient could actually be \
inside, in m, h, d or w.",
manifest::one_line(w)
)
}
fn too_far_expires(w: &str) -> String {
format!(
"--expires {:?} is past 9999-12-31T23:59:59Z once its zone offset is \
applied, and gage cannot write an expiry beyond that. Pass an \
instant inside that range.",
manifest::one_line(w)
)
}
pub fn parse_duration(w: &str) -> Result<i64, String> {
let w = w.trim();
let (n, unit) = w.split_at(w.len().saturating_sub(1));
let n: i64 = n.parse().map_err(|_| {
format!(
"--ttl wants a duration like 24h, 7d or 30m, not {:?}",
manifest::one_line(w)
)
})?;
if n <= 0 {
return Err("--ttl must be positive; a bundle that expires now or earlier \
cannot be opened by anybody"
.into());
}
let per = match unit {
"m" => 60,
"h" => 3_600,
"d" => 86_400,
"w" => 604_800,
_ => {
return Err(format!(
"unknown unit {:?} in --ttl; use m, h, d or w",
manifest::one_line(unit)
));
}
};
n.checked_mul(per)
.filter(|secs| *secs <= MAX_EXPIRY)
.ok_or_else(|| too_far(w))
}
pub fn send_bundle(
a: SendArgs,
say: &mut Vec<String>,
) -> Result<manifest::Manifest, Box<dyn std::error::Error>> {
let SendArgs {
to,
subject,
out,
file,
prompt,
decision,
memory,
dir,
allow_secrets,
reply_to,
msg_type: type_in,
allow_findings,
lan,
agent,
model,
record_send,
ttl,
expires,
context,
} = a;
let r#type = type_in;
let id = identity::Identity::load()?;
let c = collect::collect(&file, &prompt, &decision, &memory, &dir, allow_secrets)?;
if !c.skipped_secret.is_empty() {
say.push("skipped (look like secrets, --allow-secrets to include):".to_string());
for f in &c.skipped_secret {
say.push(format!(" {f}"));
}
}
if !c.skipped_link.is_empty() {
say.push("skipped (symlinks -- a link's name says nothing about its target):".to_string());
for f in &c.skipped_link {
say.push(format!(" {f}"));
}
}
if !c.skipped_big.is_empty() {
say.push("skipped (over the per-item size limit):".to_string());
for f in &c.skipped_big {
say.push(format!(" {f}"));
}
}
if !c.skipped_dir.is_empty() {
let mut d = c.skipped_dir.clone();
d.sort();
d.dedup();
say.push(format!("skipped build/cache dirs: {}", d.join(", ")));
}
let routes = routes::Routes::load();
let skipped = c.skipped_secret.len() + c.skipped_big.len() + c.skipped_link.len();
let specs = c.items;
if specs.is_empty() {
if skipped > 0 {
return Err(format!(
"nothing left to send: all {skipped} item(s) were skipped \
(listed above). Rename them, pick different paths, or pass \
--allow-secrets if you are sure."
)
.into());
}
return Err("nothing to send: pass --file, --dir, --prompt, --decision or --memory".into());
}
if to.is_empty() {
return Err("no recipient: pass --to <handle|group:name>".into());
}
let findings = scrub::scan(&specs);
if !findings.is_empty() {
let mut lines = vec![format!(
"outbound scan found {} item(s) that should not leave:",
findings.len()
)];
for f in &findings {
lines.push(format!(
" {}:{} [{}] {}",
f.path, f.line, f.rule, f.preview
));
}
if !allow_findings {
lines.push(format!(
"refusing to build this bundle. Fix the files, add a rule to {}, \
or pass --allow-findings if they are false positives.",
crate::scrub::policy_path().display()
));
return Err(lines.join("\n").into());
}
for l in &lines {
say.push(l.to_string());
}
say.push(" --allow-findings given: sending anyway".to_string());
}
let msg_type = manifest::MsgType::parse(&r#type).ok_or_else(|| {
format!(
"unknown --type {type_in:?}; use share, question, answer, review-request or ack",
type_in = r#type
)
})?;
let parent = reply_to.as_ref().and_then(|rid| find_manifest(rid));
let thread = reply_to.as_ref().map(|rid| match &parent {
Some(m) => m.thread_id().to_string(),
None => rid.clone(),
});
let context = match context {
Some(c) if c.trim().is_empty() => None,
Some(c) => Some(c),
None => parent.as_ref().and_then(|m| m.context.clone()),
};
let expires = resolve_expiry(ttl.as_deref(), expires.as_deref(), crate::bundle::now_secs())?;
let ag = agent.map(|name| AgentInfo { name, model });
if let Some(p) = &out {
refuse_out_in_records(
p,
"A bundle you built is not an arrival, and putting it there would make it \
indistinguishable from one. Write it anywhere else and hand it over, or drop \
--out and let gage deliver it.",
)?;
}
let stage = out
.clone()
.unwrap_or_else(|| identity::home().join(format!("outbox/{}", std::process::id())));
let m = bundle::create(
&stage,
&id,
&subject,
&to,
ag,
reply_to,
thread,
msg_type,
expires,
context,
&specs,
)?;
let sent = routes::sent_dir(&identity::home()).join(&m.id);
if !sent.exists() {
if let Some(parent) = sent.parent() {
std::fs::create_dir_all(parent)?;
}
routes::deliver(&stage, &sent)?;
}
let mut delivered = Vec::new();
for who in &to {
if let Some(h) = routes.home_of(who) {
let dest = routes::pending_dir(&h).join(&m.id);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
routes::deliver(&stage, &dest)?;
delivered.push(format!("{who} -> {}", dest.display()));
}
}
if record_send {
let contacts = trust::Contacts::load();
let addressees: Vec<journal::Addressee> = to
.iter()
.map(|who| journal::Addressee {
named: who.clone(),
key: contacts.key_for_name(who),
routed: routes.home_of(who).is_some(),
})
.collect();
let manifest_bytes = std::fs::read(sent.join("manifest.json"))
.or_else(|_| std::fs::read(stage.join("manifest.json")))
.unwrap_or_default();
if let Err(e) = journal::record_sent(&m, &manifest_bytes, addressees) {
say.push(format!(
" warning: the bundle went but the send journal did not record it: {e}"
));
}
}
say.push(format!(
"bundle {} [{}] -- {} item(s), {} bytes, signed by {}",
m.id,
m.msg_type.label(),
m.items.len(),
m.total_bytes(),
id.handle
));
if m.memory_count() > 0 {
say.push(format!(
" {} MEMORY item(s): these persist in whoever accepts them",
m.memory_count()
));
}
if let Some(e) = &m.expires {
say.push(format!(
" expires {e} -- after that the recipient's gage withholds the payload"
));
}
if let Some(c) = &m.context {
say.push(format!(
" context {:?} -- shown to the recipient to compare with the run they are in",
manifest::safe_display(c, 160)
));
}
for d in &delivered {
say.push(format!(" delivered {d}"));
}
let unrouted: Vec<String> = to
.iter()
.filter(|w| routes.home_of(w).is_none())
.cloned()
.collect();
if !unrouted.is_empty() && !lan {
say.push(format!(" no local route for: {}", unrouted.join(", ")));
say.push(format!(
" bundle is at {} -- move it yourself, or",
stage.display()
));
say.push(" gage route <handle> <their-gage-home> to deliver automatically".to_string());
}
Ok(m)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_ttl_becomes_an_absolute_instant_in_the_form_this_tree_writes() {
let now = 1_793_000_000;
let out = resolve_expiry(Some("7d"), None, now).unwrap().unwrap();
assert_eq!(out, crate::bundle::rfc3339(now + 7 * 86_400));
assert_eq!(
crate::bundle::parse_rfc3339(&out),
Some(now + 7 * 86_400),
"{out}"
);
for (w, secs) in [("30m", 1_800), ("24h", 86_400), ("2w", 1_209_600)] {
assert_eq!(parse_duration(w), Ok(secs), "{w}");
}
}
#[test]
fn an_absolute_expiry_is_canonicalised_rather_than_passed_through() {
let now = 1_793_000_000;
let at = crate::bundle::rfc3339(now + 3_600);
let typed = at.replace('Z', "+00:00");
assert_eq!(
resolve_expiry(None, Some(&typed), now).unwrap(),
Some(at.clone())
);
assert_eq!(resolve_expiry(None, Some(&at), now).unwrap(), Some(at));
}
#[test]
fn the_ways_of_getting_an_expiry_wrong_are_refused_at_send_time() {
let now = 1_793_000_000;
assert!(resolve_expiry(Some("7d"), Some("2026-11-09T09:00:00Z"), now).is_err());
assert!(resolve_expiry(None, Some(&crate::bundle::rfc3339(now - 1)), now).is_err());
assert!(resolve_expiry(None, Some(&crate::bundle::rfc3339(now)), now).is_err());
assert!(resolve_expiry(None, Some("next tuesday"), now).is_err());
assert!(resolve_expiry(None, Some("2026-11-09T09:00:00"), now).is_err());
for bad in ["7y", "abc", "", "0d", "-1d", "d", "1 d"] {
assert!(parse_duration(bad).is_err(), "{bad:?} should be refused");
}
assert_eq!(resolve_expiry(None, None, now), Ok(None));
}
#[test]
fn a_ttl_too_large_to_mean_anything_is_refused_rather_than_wrapped() {
let now = 1_793_000_000;
for w in [
"9223372036854775807m",
"9223372036854775807h",
"9223372036854775807d",
"9223372036854775807w",
"153722867280912930w",
"99999999999999w",
"1000000000000000000d",
] {
let e = parse_duration(w).unwrap_err();
assert!(e.contains("too far out"), "{w}: {e}");
let r = resolve_expiry(Some(w), None, now);
assert!(r.is_err(), "{w} resolved to {r:?}");
}
let edge = MAX_EXPIRY - now;
let out = resolve_expiry(Some(&format!("{edge}m")), None, now);
assert!(out.is_err(), "a window {edge} minutes long is not writable");
assert!(parse_duration(&format!("{MAX_EXPIRY}m")).is_err());
for (w, secs) in [
("1m", 60),
("30m", 1_800),
("24h", 86_400),
("7d", 604_800),
("52w", 31_449_600),
] {
assert_eq!(parse_duration(w), Ok(secs), "{w}");
assert_eq!(
resolve_expiry(Some(w), None, now),
Ok(Some(crate::bundle::rfc3339(now + secs))),
"{w}"
);
}
}
#[test]
fn the_absolute_expiry_path_is_bounded_on_the_normalized_utc_instant() {
let now = 1_793_000_000;
for w in ["9999-12-31T23:59:59Z", "9999-12-31T23:59:59+00:00"] {
let out = resolve_expiry(None, Some(w), now)
.unwrap_or_else(|e| panic!("{w} refused: {e}"))
.unwrap();
assert_eq!(out, "9999-12-31T23:59:59Z", "{w}");
assert_eq!(crate::bundle::parse_rfc3339(&out), Some(MAX_EXPIRY), "{w}");
}
assert_eq!(
crate::bundle::parse_rfc3339("9999-12-31T23:59:59Z"),
Some(MAX_EXPIRY)
);
let e = resolve_expiry(None, Some("9999-12-31T23:59:59-23:59"), now).unwrap_err();
assert!(e.contains("9999-12-31T23:59:59Z"), "{e}");
for w in [
"9999-12-31T23:59:59-23:59",
"9999-12-31T23:59:59-00:01",
"9999-12-31T23:59:60Z",
"9999-12-31T00:00:00-23:59",
] {
if let Ok(Some(out)) = resolve_expiry(None, Some(w), now) {
let back = crate::bundle::parse_rfc3339(&out)
.unwrap_or_else(|| panic!("{w} produced an unreadable {out}"));
assert!(back <= MAX_EXPIRY, "{w} -> {out}");
}
}
for w in ["10000-01-01T00:00:00Z", "99999-01-01T00:00:00Z"] {
let e = resolve_expiry(None, Some(w), now).unwrap_err();
assert!(e.contains("RFC 3339 timestamp"), "{w}: {e}");
}
let e = resolve_expiry(None, Some("1969-07-20T20:17:00Z"), now).unwrap_err();
assert!(e.contains("already past"), "{e}");
}
#[test]
fn the_expiry_bound_is_decided_on_the_instant_not_on_the_typed_fields() {
let now = 1_793_000_000;
assert_eq!(
crate::bundle::parse_rfc3339("10000-01-01T00:30:00+23:59"),
None
);
let out = resolve_expiry(None, Some("9999-12-31T23:59:59+23:59"), now)
.unwrap()
.unwrap();
assert_eq!(out, "9999-12-31T00:00:59Z");
assert_eq!(
crate::bundle::parse_rfc3339(&out),
Some(MAX_EXPIRY - 86_340)
);
}
#[test]
fn the_resolved_expiry_is_always_one_this_tree_can_write_and_read_back() {
let now = 1_793_000_000;
for w in ["1m", "90m", "36h", "365d", "4000w", "100000d"] {
let out = resolve_expiry(Some(w), None, now).unwrap().unwrap();
let back = crate::bundle::parse_rfc3339(&out)
.unwrap_or_else(|| panic!("{w} produced an unreadable {out}"));
assert_eq!(back, now + parse_duration(w).unwrap(), "{w} -> {out}");
assert!(back > now, "{w} -> {out} is not in the future");
}
}
}