1use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25use std::sync::atomic::{AtomicU64, Ordering};
26
27use serde::{Deserialize, Serialize};
28
29const CHANNEL_WRAPPER_TOKENS: &[&str] = &["</channel>", "<channel source="];
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Envelope {
33 pub from: String,
34 pub text: String,
35 pub ts: String,
36}
37
38pub fn valid_agent_id(id: &str) -> bool {
42 match id.strip_prefix("agent") {
43 Some(suffix) => {
44 !suffix.is_empty()
45 && suffix
46 .chars()
47 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
48 }
49 None => false,
50 }
51}
52
53pub fn build_filename(from: &str) -> String {
57 static SEQ: AtomicU64 = AtomicU64::new(0);
58 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
59 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0);
60 let pid = std::process::id();
61 let rand_hex: u32 = rand::random();
62 format!("{nanos:020}-{pid:010}-{rand_hex:08x}-{seq:010}-from-{from}.json")
63}
64
65pub fn write_envelope(inbox: &Path, env: &Envelope) -> io::Result<PathBuf> {
69 fs::create_dir_all(inbox)?;
70 let bytes =
71 serde_json::to_vec(env).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
72 let mut last_err: Option<io::Error> = None;
73 for attempt in 0..8 {
74 let name = build_filename(&env.from);
75 let final_path = inbox.join(&name);
76 let tmp_path = inbox.join(format!(".{name}.{attempt}.tmp"));
77 fs::write(&tmp_path, &bytes)?;
78 match fs::hard_link(&tmp_path, &final_path) {
79 Ok(()) => {
80 let _ = fs::remove_file(&tmp_path);
81 return Ok(final_path);
82 }
83 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
84 let _ = fs::remove_file(&tmp_path);
85 last_err = Some(e);
86 continue;
87 }
88 Err(e) => {
89 let _ = fs::remove_file(&tmp_path);
90 return Err(e);
91 }
92 }
93 }
94 Err(last_err.unwrap_or_else(|| {
95 io::Error::new(
96 io::ErrorKind::AlreadyExists,
97 "exhausted 8 attempts to create unique envelope filename",
98 )
99 }))
100}
101
102pub fn body_contains_wrapper_tokens(body: &str) -> bool {
104 CHANNEL_WRAPPER_TOKENS
105 .iter()
106 .any(|needle| body.contains(needle))
107}
108
109pub fn validate_bus_envelope(env: &Envelope) -> Result<(), String> {
112 if !valid_agent_id(&env.from) {
113 return Err(format!(
114 "invalid from {:?} (expected agent<lowercase-alnum>)",
115 env.from
116 ));
117 }
118 if chrono::DateTime::parse_from_rfc3339(&env.ts).is_err() {
119 return Err(format!("invalid ts {:?} (expected RFC3339)", env.ts));
120 }
121 if body_contains_wrapper_tokens(&env.text) {
122 return Err("body contains <channel> wrapper token; framing-break rejected".to_string());
123 }
124 Ok(())
125}
126
127pub fn xml_escape_body(s: &str) -> String {
130 let mut out = String::with_capacity(s.len());
131 for c in s.chars() {
132 match c {
133 '&' => out.push_str("&"),
134 '<' => out.push_str("<"),
135 '>' => out.push_str(">"),
136 '"' => out.push_str("""),
137 '\'' => out.push_str("'"),
138 other => out.push(other),
139 }
140 }
141 out
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn valid_agent_id_accepts_canonical_shapes() {
150 assert!(valid_agent_id("agent0"));
151 assert!(valid_agent_id("agent42"));
152 assert!(valid_agent_id("agentinfinity"));
153 }
154
155 #[test]
156 fn valid_agent_id_rejects_junk() {
157 assert!(!valid_agent_id(""));
158 assert!(!valid_agent_id("agent"));
159 assert!(!valid_agent_id("AGENT0"));
160 assert!(!valid_agent_id("agent-7"));
161 assert!(!valid_agent_id("bob"));
162 assert!(!valid_agent_id("agent 0"));
163 }
164
165 #[test]
166 fn build_filename_shape() {
167 let n = build_filename("agent7");
168 assert!(n.ends_with("-from-agent7.json"));
169 let parts: Vec<&str> = n.splitn(5, '-').collect();
170 assert_eq!(parts.len(), 5);
171 assert_eq!(parts[0].len(), 20);
172 assert_eq!(parts[1].len(), 10);
173 assert_eq!(parts[2].len(), 8);
174 }
175
176 #[test]
177 fn build_filename_is_unique_within_process() {
178 let mut seen = std::collections::HashSet::new();
179 for _ in 0..1000 {
180 assert!(seen.insert(build_filename("agent0")));
181 }
182 }
183
184 #[test]
185 fn write_envelope_round_trips() {
186 let tmp = tempfile::tempdir().unwrap();
187 let env = Envelope {
188 from: "agent2".to_string(),
189 text: "hello".to_string(),
190 ts: "2026-04-15T21:00:00Z".to_string(),
191 };
192 let path = write_envelope(tmp.path(), &env).unwrap();
193 let raw = fs::read_to_string(&path).unwrap();
194 let round: Envelope = serde_json::from_str(&raw).unwrap();
195 assert_eq!(round.from, env.from);
196 assert_eq!(round.text, env.text);
197 }
198
199 #[test]
200 fn write_envelope_rejects_name_collision() {
201 let tmp = tempfile::tempdir().unwrap();
206 let env = Envelope {
207 from: "agent2".to_string(),
208 text: "hi".to_string(),
209 ts: "2026-04-15T21:00:00Z".to_string(),
210 };
211 let a = write_envelope(tmp.path(), &env).unwrap();
212 let b = write_envelope(tmp.path(), &env).unwrap();
213 assert_ne!(a, b);
214 }
215}