1use std::collections::BTreeSet;
24
25#[derive(Debug, Clone, Default)]
30pub struct ScrubContext {
31 #[allow(dead_code)]
36 pub account_id: Option<String>,
37 pub profile: Option<String>,
38 pub region: Option<String>,
39 pub env_names: BTreeSet<String>,
43 pub app_names: BTreeSet<String>,
45 pub cnames: BTreeSet<String>,
48}
49
50pub struct ReportInput<'a> {
56 pub ebman_version: &'a str,
57 pub os: &'a str,
58 pub os_release: &'a str,
59 pub icons: &'a str,
60 pub theme: &'a str,
61 pub refresh_interval_secs: u64,
62 pub recent_log_lines: Vec<String>,
65 pub recent_messages: Vec<String>,
69 pub recent_crash: Option<String>,
72 pub env_count: usize,
75 pub app_count: usize,
76 pub multi_regions_count: usize,
77 pub multi_account_enabled: bool,
78}
79
80pub fn build_report(input: &ReportInput<'_>, ctx: &ScrubContext) -> String {
89 let mut body = String::new();
90 body.push_str("## ebman bug report\n\n");
91 body.push_str("(Account IDs / ARNs / env names / profiles / CNAMEs are scrubbed.\n");
92 body.push_str("Review before pasting; some freeform errors may still embed identifiers.)\n\n");
93
94 body.push_str("### Environment\n");
95 body.push_str(&format!("ebman: {}\n", input.ebman_version));
96 body.push_str(&format!("os: {} / {}\n", input.os, input.os_release));
97 body.push_str(&format!("icons: {}\n", input.icons));
98 body.push_str(&format!("theme: {}\n", input.theme));
99 body.push_str(&format!(
100 "refresh_interval: {}s\n",
101 input.refresh_interval_secs
102 ));
103 body.push_str(&format!(
104 "scope: envs={}, apps={}, multi_regions={}, multi_account={}\n",
105 input.env_count, input.app_count, input.multi_regions_count, input.multi_account_enabled
106 ));
107 body.push('\n');
108
109 if !input.recent_messages.is_empty() {
110 body.push_str("### Recent on-screen messages\n");
111 body.push_str("```\n");
112 for msg in &input.recent_messages {
113 body.push_str(msg);
114 body.push('\n');
115 }
116 body.push_str("```\n\n");
117 }
118
119 if !input.recent_log_lines.is_empty() {
120 body.push_str("### Last 30 lines of ebman.log\n");
121 body.push_str("```\n");
122 for line in &input.recent_log_lines {
123 body.push_str(line);
124 body.push('\n');
125 }
126 body.push_str("```\n\n");
127 }
128
129 if let Some(crash) = &input.recent_crash {
130 body.push_str("### Most recent panic backtrace\n");
131 body.push_str("```\n");
132 body.push_str(crash);
133 if !crash.ends_with('\n') {
134 body.push('\n');
135 }
136 body.push_str("```\n\n");
137 }
138
139 body.push_str("### What were you doing?\n");
140 body.push_str("<!-- Describe the action that triggered the bug — what command,\n");
141 body.push_str(" what env, what was the expected result. -->\n\n");
142
143 scrub(&body, ctx)
144}
145
146pub fn scrub(text: &str, ctx: &ScrubContext) -> String {
150 let mut out = text.to_string();
151
152 out = scrub_pattern(&out, "arn:aws:", "[arn]");
156 out = scrub_pattern(&out, "arn:aws-us-gov:", "[arn]");
157 out = scrub_pattern(&out, "arn:aws-cn:", "[arn]");
158
159 let mut env_names: Vec<&String> = ctx.env_names.iter().collect();
163 env_names.sort_by_key(|n| std::cmp::Reverse(n.len()));
164 for name in &env_names {
165 if !name.is_empty() {
166 out = out.replace(name.as_str(), "[env]");
167 }
168 }
169
170 let mut app_names: Vec<&String> = ctx.app_names.iter().collect();
172 app_names.sort_by_key(|n| std::cmp::Reverse(n.len()));
173 for name in &app_names {
174 if !name.is_empty() {
175 out = out.replace(name.as_str(), "[app]");
176 }
177 }
178
179 let mut cnames: Vec<&String> = ctx.cnames.iter().collect();
182 cnames.sort_by_key(|n| std::cmp::Reverse(n.len()));
183 for cname in &cnames {
184 if !cname.is_empty() {
185 out = out.replace(cname.as_str(), "[cname]");
186 }
187 }
188
189 out = scrub_12_digit_numbers(&out);
192
193 if let Some(p) = ctx.profile.as_ref() {
197 if !p.is_empty() && p != "default" {
198 out = out.replace(p, "[profile]");
201 }
202 }
203
204 let _ = &ctx.region;
210
211 out
212}
213
214fn scrub_12_digit_numbers(text: &str) -> String {
218 let bytes = text.as_bytes();
219 let mut out = String::with_capacity(text.len());
220 let mut i = 0;
221 while i < bytes.len() {
222 let c = bytes[i];
223 if c.is_ascii_digit() {
224 let mut j = i;
226 while j < bytes.len() && bytes[j].is_ascii_digit() {
227 j += 1;
228 }
229 let run = j - i;
230 if run == 12 {
231 out.push_str("[account]");
235 i = j;
236 continue;
237 } else {
238 out.push_str(&text[i..j]);
240 i = j;
241 continue;
242 }
243 }
244 out.push(c as char);
247 i += 1;
248 }
249 out
250}
251
252fn scrub_pattern(text: &str, prefix: &str, replacement: &str) -> String {
257 let mut out = String::with_capacity(text.len());
258 let mut rest = text;
259 while let Some(pos) = rest.find(prefix) {
260 out.push_str(&rest[..pos]);
261 let after = &rest[pos..];
263 let mut end = 0;
264 for (i, c) in after.char_indices() {
265 if matches!(c, ' ' | '\t' | '\n' | '\r' | '"' | '\'' | ',' | ')' | ']') {
266 end = i;
267 break;
268 }
269 end = i + c.len_utf8();
270 }
271 out.push_str(replacement);
272 rest = &after[end..];
273 }
274 out.push_str(rest);
275 out
276}
277
278pub fn url_encode(s: &str) -> String {
282 let mut out = String::with_capacity(s.len() * 3);
283 for byte in s.bytes() {
284 match byte {
285 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
286 out.push(byte as char);
287 }
288 _ => {
289 out.push_str(&format!("%{byte:02X}"));
290 }
291 }
292 }
293 out
294}
295
296pub fn github_issue_url(repo: &str, title: &str, body: &str) -> String {
300 let max_body = 7900_usize.saturating_sub(title.len());
301 let truncated = if body.len() > max_body {
302 let truncated_at = body
303 .char_indices()
304 .take_while(|(i, _)| *i < max_body.saturating_sub(64))
305 .last()
306 .map(|(i, c)| i + c.len_utf8())
307 .unwrap_or(0);
308 let mut s = body[..truncated_at].to_string();
309 s.push_str("\n\n…[body truncated for URL length; paste the full payload from the overlay]");
310 s
311 } else {
312 body.to_string()
313 };
314 format!(
315 "https://github.com/{repo}/issues/new?title={t}&body={b}",
316 t = url_encode(title),
317 b = url_encode(&truncated)
318 )
319}
320
321pub fn latest_crash_log() -> Option<String> {
325 let dir = crate::util::cache_dir();
326 let mut crashes: Vec<std::fs::DirEntry> = std::fs::read_dir(&dir)
327 .ok()?
328 .filter_map(|e| e.ok())
329 .filter(|e| e.file_name().to_string_lossy().starts_with("crash-"))
330 .collect();
331 crashes.sort_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
332 let latest = crashes.last()?;
333 std::fs::read_to_string(latest.path()).ok()
334}
335
336pub fn tail_ebman_log(n: usize) -> Vec<String> {
340 let mut path = crate::util::cache_dir();
341 path.push("ebman.log");
342 let Ok(text) = std::fs::read_to_string(&path) else {
343 return Vec::new();
344 };
345 let lines: Vec<&str> = text.lines().collect();
346 let start = lines.len().saturating_sub(n);
347 lines[start..].iter().map(|s| s.to_string()).collect()
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 fn ctx_with(env_names: &[&str], app_names: &[&str], profile: Option<&str>) -> ScrubContext {
355 ScrubContext {
356 account_id: None,
357 profile: profile.map(String::from),
358 region: Some("eu-west-2".into()),
359 env_names: env_names.iter().map(|s| (*s).to_string()).collect(),
360 app_names: app_names.iter().map(|s| (*s).to_string()).collect(),
361 cnames: BTreeSet::new(),
362 }
363 }
364
365 #[test]
366 fn scrub_redacts_12_digit_account_ids() {
367 let ctx = ctx_with(&[], &[], None);
368 let scrubbed = scrub("account 123456789012 hit a limit", &ctx);
369 assert_eq!(scrubbed, "account [account] hit a limit");
370 }
371
372 #[test]
373 fn scrub_leaves_short_numbers_alone() {
374 let ctx = ctx_with(&[], &[], None);
375 let scrubbed = scrub("port 8080 / 11 digits 12345678901", &ctx);
376 assert!(scrubbed.contains("8080"));
377 assert!(scrubbed.contains("12345678901"));
378 assert!(!scrubbed.contains("[account]"));
379 }
380
381 #[test]
382 fn scrub_redacts_arns() {
383 let ctx = ctx_with(&[], &[], None);
384 let scrubbed = scrub(
385 "Role arn:aws:iam::123456789012:role/EbmanReadOnly does not have perms",
386 &ctx,
387 );
388 assert!(scrubbed.contains("[arn]"));
389 assert!(!scrubbed.contains("arn:aws"));
390 assert!(!scrubbed.contains("EbmanReadOnly"));
391 }
392
393 #[test]
394 fn scrub_redacts_env_names_longest_first() {
395 let ctx = ctx_with(&["prod-api", "prod-api-canary"], &[], None);
396 let scrubbed = scrub("prod-api-canary went red, prod-api is yellow", &ctx);
397 assert_eq!(scrubbed, "[env] went red, [env] is yellow");
400 }
401
402 #[test]
403 fn scrub_redacts_profile_name_but_skips_default() {
404 let ctx = ctx_with(&[], &[], Some("prod-aws"));
405 let scrubbed = scrub("profile=prod-aws region=eu-west-2", &ctx);
406 assert!(scrubbed.contains("[profile]"));
407 assert!(!scrubbed.contains("prod-aws"));
408
409 let ctx_default = ctx_with(&[], &[], Some("default"));
410 let scrubbed = scrub("profile=default region=eu-west-2", &ctx_default);
411 assert!(scrubbed.contains("default"));
413 }
414
415 #[test]
416 fn url_encode_handles_special_chars() {
417 assert_eq!(url_encode("hello world"), "hello%20world");
418 assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
419 assert_eq!(url_encode("ARN: arn:aws"), "ARN%3A%20arn%3Aaws");
420 }
421
422 #[test]
423 fn github_issue_url_pre_fills_title_and_body() {
424 let url = github_issue_url("tombaldwin/ebman", "Crash on :why", "stack trace here");
425 assert!(url.starts_with("https://github.com/tombaldwin/ebman/issues/new?"));
426 assert!(url.contains("title=Crash"));
427 assert!(url.contains("body=stack"));
428 }
429
430 #[test]
431 fn github_issue_url_truncates_long_body() {
432 let long_body = "x".repeat(20_000);
433 let url = github_issue_url("tombaldwin/ebman", "Bug", &long_body);
434 assert!(url.len() < 8500, "URL must stay under GitHub's ~8k limit");
435 assert!(url.contains("body%20truncated"));
437 }
438}