use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;
#[allow(dead_code)]
static CF_RESOURCE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?m)^\d{4}-\d{2}-\d{2}T[^\s]+ [A-Z_]+ [A-Z_]+ [^\n]+\n?").unwrap());
static PROGRESS_BAR_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?m)^\s*[-=]{3,}[^\n]*\n?").unwrap());
pub fn compress_cloudformation(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let out: Vec<&str> = cleaned
.lines()
.filter(|l| !l.contains("_IN_PROGRESS") || l.contains("FAILED") || l.contains("ROLLBACK"))
.collect();
compactor::collapse_blanks(&out.join("\n"))
}
pub fn compress_s3(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let s = PROGRESS_BAR_RE.replace_all(&cleaned, "");
let out: Vec<&str> = s
.lines()
.filter(|l| {
l.trim_start().starts_with("upload:")
|| l.trim_start().starts_with("download:")
|| l.trim_start().starts_with("copy:")
|| l.trim_start().starts_with("delete:")
|| l.contains("error")
|| l.contains("Error")
|| l.contains("fatal")
})
.collect();
if out.is_empty() {
s.into_owned()
} else {
out.join("\n")
}
}
pub fn compress_generic_aws(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().collect();
if lines.len() > 40 {
return format!(
"{}\n... [{} more lines — use --query to filter] ...",
lines[..40].join("\n"),
lines.len() - 40
);
}
cleaned
}
pub fn compress_logs(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
super::super::sys::log_dedup(&cleaned)
}
pub fn compress_lambda(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let noisy: &[&str] = &[
"\"FunctionArn\"",
"\"Role\"",
"\"CodeSha256\"",
"\"RevisionId\"",
"\"LastModified\"",
"\"CodeSize\"",
"\"Layers\"",
"\"VpcConfig\"",
"\"TracingConfig\"",
];
let out: Vec<&str> = cleaned
.lines()
.filter(|l| noisy.iter().all(|n| !l.contains(n)))
.collect();
compactor::collapse_blanks(&out.join("\n"))
}
pub fn compress_ecs(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.len() <= 30 {
return lines.join("\n");
}
format!(
"{}\n... [{} more lines] ...",
lines[..30].join("\n"),
lines.len() - 30
)
}
pub fn compress_iam(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
if cleaned.contains("\"SecretAccessKey\"") || cleaned.contains("\"SessionToken\"") {
return compress_iam_assume_role(&cleaned);
}
if cleaned.contains("\"Arn\"")
&& (cleaned.contains("\"PolicyName\"")
|| cleaned.contains("\"RoleName\"")
|| cleaned.contains("\"UserName\"")
|| cleaned.contains("\"GroupName\""))
{
return compress_iam_list(&cleaned);
}
let noisy: &[&str] = &[
"\"PolicyDocument\":",
"\"AssumeRolePolicyDocument\":",
"\"Statement\":",
"\"Action\":",
"\"Effect\":",
"\"Resource\":",
"\"Condition\":",
"\"Principal\":",
"\"Path\":",
"\"PolicyId\":",
"\"AttachmentCount\":",
"\"PermissionsBoundaryUsageCount\":",
"\"IsAttachable\":",
"\"DefaultVersionId\":",
];
let out: Vec<&str> = cleaned
.lines()
.filter(|l| noisy.iter().all(|n| !l.trim().starts_with(n)))
.collect();
let lines = out.as_slice();
if lines.len() <= 40 {
return lines.join("\n");
}
format!(
"{}\n… [{} more lines — use --query to filter] …",
lines[..40].join("\n"),
lines.len() - 40
)
}
fn compress_iam_list(raw: &str) -> String {
use once_cell::sync::Lazy;
use regex::Regex;
static NAME_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#""(PolicyName|RoleName|UserName|GroupName)"\s*:\s*"([^"]+)""#).unwrap()
});
static ARN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""Arn"\s*:\s*"([^"]+)""#).unwrap());
let names: Vec<&str> = NAME_RE
.captures_iter(raw)
.filter_map(|c| c.get(2).map(|m| m.as_str()))
.collect();
let arns: Vec<&str> = ARN_RE
.captures_iter(raw)
.filter_map(|c| c.get(1).map(|m| m.as_str()))
.collect();
if names.is_empty() {
return compactor::collapse_blanks(raw);
}
let count = names.len();
let lines: Vec<String> = names
.iter()
.zip(arns.iter().chain(std::iter::repeat(&"")))
.map(|(name, arn)| {
if arn.is_empty() {
(*name).to_string()
} else {
format!("{name} {arn}")
}
})
.take(20)
.collect();
let mut result = lines.join("\n");
if count > 20 {
result.push_str(&format!("\n… {} more (use --query to filter)", count - 20));
} else {
result.push_str(&format!("\n({count} total)"));
}
result
}
fn compress_iam_assume_role(raw: &str) -> String {
use once_cell::sync::Lazy;
use regex::Regex;
static KEY_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#""AccessKeyId"\s*:\s*"([^"]{8})[^"]*""#).unwrap());
static EXP_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#""Expiration"\s*:\s*"([^"]+)""#).unwrap());
static ARN_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#""AssumedRoleId"\s*:\s*"([^"]+)""#).unwrap());
let key = KEY_RE
.captures(raw)
.map(|c| format!("{}…", &c[1]))
.unwrap_or_else(|| "REDACTED".to_string());
let expiry = EXP_RE
.captures(raw)
.map(|c| c[1].to_string())
.unwrap_or_else(|| "unknown".to_string());
let role = ARN_RE
.captures(raw)
.map(|c| c[1].to_string())
.unwrap_or_else(|| "role".to_string());
format!(
"sts:AssumeRole — AccessKeyId: {key} Expiration: {expiry} Role: {role}\n(credentials redacted)"
)
}
pub fn compress_sts(raw: &str) -> String {
if raw.contains("\"SecretAccessKey\"") {
return compress_iam_assume_role(raw);
}
compactor::normalise(raw)
}
pub fn compress_aws(subcmd: &str, raw: &str) -> String {
let sub = subcmd.trim();
if sub.starts_with("cloudformation") {
return compress_cloudformation(raw);
}
if sub.starts_with("s3") {
return compress_s3(raw);
}
if sub.starts_with("logs") {
return compress_logs(raw);
}
if sub.starts_with("lambda") {
return compress_lambda(raw);
}
if sub.starts_with("ecs") {
return compress_ecs(raw);
}
if sub.starts_with("iam") {
return compress_iam(raw);
}
if sub.starts_with("sts") {
return compress_sts(raw);
}
compress_generic_aws(raw)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cloudformation_strips_in_progress() {
let raw = "MyBucket AWS::S3::Bucket CREATE_IN_PROGRESS\nMyBucket AWS::S3::Bucket CREATE_COMPLETE\n";
let out = compress_cloudformation(raw);
assert!(!out.contains("CREATE_IN_PROGRESS"), "{out}");
assert!(out.contains("CREATE_COMPLETE"));
}
#[test]
fn s3_keeps_upload_lines() {
let raw =
"upload: ./foo.txt to s3://bucket/foo.txt\nCompleted 1.0 MiB/1.0 MiB (500 KiB/s)\n";
let out = compress_s3(raw);
assert!(out.contains("upload:"));
}
#[test]
fn generic_truncates_large_json() {
let raw = (0..50)
.map(|i| format!(" \"key{i}\": \"value{i}\","))
.collect::<Vec<_>>()
.join("\n");
let out = compress_generic_aws(&raw);
assert!(out.contains("more lines"), "{out}");
}
#[test]
fn lambda_strips_noisy_fields() {
let raw = "{\n \"FunctionName\": \"my-fn\",\n \"FunctionArn\": \"arn:aws:lambda:us-east-1:123:function:my-fn\",\n \"Runtime\": \"nodejs18.x\",\n \"Role\": \"arn:aws:iam::123:role/role\",\n \"CodeSha256\": \"abc123\"\n}\n";
let out = compress_lambda(raw);
assert!(!out.contains("FunctionArn"), "{out}");
assert!(!out.contains("CodeSha256"), "{out}");
assert!(out.contains("FunctionName"), "{out}");
assert!(out.contains("Runtime"), "{out}");
}
#[test]
fn logs_deduplicates_repeated_entries() {
let raw = "[ERROR] timeout\n[ERROR] timeout\n[ERROR] timeout\n[INFO] recovered\n";
let out = compress_logs(raw);
assert!(
out.contains("repeated") || out.contains("[ERROR] timeout"),
"{out}"
);
assert!(out.contains("[INFO] recovered"), "{out}");
}
#[test]
fn iam_list_policies_extracts_names() {
let raw = r#"{"Policies":[{"PolicyName":"AdminPolicy","Arn":"arn:aws:iam::123456789:policy/AdminPolicy","AttachmentCount":3,"IsAttachable":true},{"PolicyName":"ReadOnly","Arn":"arn:aws:iam::123456789:policy/ReadOnly","AttachmentCount":1,"IsAttachable":true}]}"#;
let out = compress_iam(raw);
assert!(out.contains("AdminPolicy"), "{out}");
assert!(out.contains("ReadOnly"), "{out}");
assert!(!out.contains("AttachmentCount"), "{out}");
}
#[test]
fn iam_assume_role_redacts_credentials() {
let raw = r#"{"Credentials":{"AccessKeyId":"ASIAIOSFODNN7EXAMPLE","SecretAccessKey":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY","SessionToken":"AQoXnyc4lcK4w//abcdef","Expiration":"2026-06-01T12:00:00Z"},"AssumedRoleId":"AROAIOSFODNN7EXAMPLE:session"}"#;
let out = compress_iam(raw);
assert!(!out.contains("SecretAccessKey"), "{out}");
assert!(!out.contains("SessionToken"), "{out}");
assert!(
out.contains("ASIAIOSF") || out.contains("REDACTED"),
"{out}"
);
assert!(out.contains("Expiration") || out.contains("2026"), "{out}");
}
#[test]
fn iam_policy_document_strips_statement_fields() {
let raw = r#"{"Policy":{"PolicyName":"MyPolicy","PolicyId":"ANPAI3VAJF5ZCREXAMPLE","Arn":"arn:aws:iam::123:policy/MyPolicy","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":"*"}]}}"#;
let out = compress_iam(raw);
assert!(out.contains("MyPolicy"), "{out}");
assert!(!out.contains("\"Effect\""), "{out}");
assert!(!out.contains("\"Action\""), "{out}");
}
#[test]
fn sts_redacts_assume_role_credentials() {
let raw = r#"{"Credentials":{"AccessKeyId":"TESTIOSFODNN7EXAMPLE","SecretAccessKey":"secret","SessionToken":"token","Expiration":"2026-06-01T00:00:00Z"}}"#;
let out = compress_sts(raw);
assert!(!out.contains("SecretAccessKey"), "{out}");
assert!(!out.contains("secret"), "{out}");
}
}