agentswap 0.12.0

AgentSwap CLI for requesting quotes, inspecting tokens and pools, managing intents, and registering an API key against the AgentSwap service.
// Bounded, credential-redacted diagnostics from the public broadcast stream.
// Only diagnostic JSON fields are retained; request/auth echoes are discarded.

const MAX_BODY: usize = 16_384;
const MAX_DIAGNOSTIC: usize = 768;

pub(super) async fn diagnostic(mut response: reqwest::Response) -> String {
    let mut bytes = Vec::new();
    loop {
        match response.chunk().await {
            Ok(Some(chunk)) if bytes.len() + chunk.len() <= MAX_BODY => {
                bytes.extend_from_slice(&chunk)
            }
            Ok(Some(_)) => return "[response omitted: too large; truncated]".into(),
            Ok(None) => return summarize(&bytes),
            Err(_) => return "[response body could not be read]".into(),
        }
    }
}

fn summarize(bytes: &[u8]) -> String {
    let text = String::from_utf8_lossy(bytes);
    let selected = match serde_json::from_slice::<serde_json::Value>(bytes) {
        Ok(value) => diagnostic_fields(&value),
        Err(_) if text.trim_start().starts_with(['{', '[']) => "[invalid JSON body omitted]".into(),
        Err(_) => text.into_owned(),
    };
    let truncated = selected.chars().count() > MAX_DIAGNOSTIC;
    let clean = scrub(&selected);
    let mut summary: String = clean.chars().take(MAX_DIAGNOSTIC).collect();
    if truncated || clean.chars().count() > MAX_DIAGNOSTIC {
        summary.push_str(" [truncated]");
    }
    if summary.is_empty() {
        summary.push_str("[no diagnostic body]");
    }
    summary
}

fn diagnostic_fields(value: &serde_json::Value) -> String {
    match value {
        serde_json::Value::String(text) => text.clone(),
        serde_json::Value::Number(number) => number.to_string(),
        serde_json::Value::Object(fields) => {
            ["error", "message", "reason", "code", "status", "detail"]
                .iter()
                .filter_map(|key| fields.get(*key).map(diagnostic_fields))
                .filter(|text| !text.is_empty())
                .collect::<Vec<_>>()
                .join("; ")
        }
        _ => String::new(),
    }
}

fn scrub(text: &str) -> String {
    let text = crate::redact::urls(text);
    let safe = redact_label_values(&text);
    safe.split_whitespace()
        .map(|word| {
            let token = word.trim_matches(|c: char| !c.is_ascii_alphanumeric());
            if token.len() > 128
                || (token.len() >= 64
                    && token
                        .trim_start_matches("0x")
                        .chars()
                        .all(|c| c.is_ascii_hexdigit()))
            {
                "[redacted]".to_string()
            } else {
                word.chars().filter(|c| !c.is_control()).collect()
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

const CREDENTIAL_LABELS: &[&str] = &[
    "key",
    "api_key",
    "apikey",
    "api-key",
    "authorization",
    "bearer",
    "password",
    "secret",
    "token",
    "auth",
    "cookie",
    "credential",
    "private key",
    "signature",
    "envelope",
];

// Recognize complete credential labels, retaining their punctuation and surrounding reasons.
fn redact_label_values(text: &str) -> String {
    let lower = text.to_ascii_lowercase();
    let mut out = String::new();
    let mut copied = 0;
    for (index, _) in text.char_indices() {
        if index < copied
            || text[..index]
                .chars()
                .next_back()
                .is_some_and(|c| c.is_alphanumeric() || matches!(c, '_' | '-'))
        {
            continue;
        }
        for &label in CREDENTIAL_LABELS {
            if !lower[index..].starts_with(label) {
                continue;
            }
            let after = index + label.len();
            let Some(offset) = label_value_start(&text[after..], label == "bearer") else {
                continue;
            };
            let start = after + offset;
            if start == text.len() {
                continue;
            }
            out.push_str(&text[copied..start]);
            out.push_str("[redacted]");
            copied = value_end(text, start);
            break;
        }
    }
    out.push_str(&text[copied..]);
    out
}

fn label_value_start(tail: &str, bearer: bool) -> Option<usize> {
    let value = if bearer {
        if !tail.starts_with(char::is_whitespace) {
            return None;
        }
        tail.trim_start()
    } else {
        let tail = tail.strip_prefix(['"', '\'']).unwrap_or(tail).trim_start();
        tail.strip_prefix(['=', ':'])?.trim_start()
    };
    Some(tail.len() - value.len())
}

fn value_end(text: &str, start: usize) -> usize {
    let mut value = &text[start..];
    // Authorization values can include the scheme before the credential.
    if value
        .get(..6)
        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("bearer"))
        && value[6..].starts_with(char::is_whitespace)
    {
        value = value[6..].trim_start();
    }
    let start = text.len() - value.len();
    if value.starts_with(['{', '[']) {
        let mut json = serde_json::Deserializer::from_str(value).into_iter::<serde_json::Value>();
        return if matches!(json.next(), Some(Ok(_))) {
            start + json.byte_offset()
        } else {
            text.len()
        };
    }
    if let Some(quote) = value.chars().next().filter(|c| matches!(c, '"' | '\'')) {
        let mut escaped = false;
        for (index, c) in value[1..].char_indices() {
            if escaped {
                escaped = false;
            } else if c == '\\' {
                escaped = true;
            } else if c == quote {
                return start + index + 2;
            }
        }
        return text.len();
    }
    start
        + value
            .find(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | '}' | ']'))
            .unwrap_or(value.len())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ordinary_reasons_survive_and_only_label_values_are_redacted() {
        for reason in [
            "invalid signature",
            "authentication failed",
            "unsupported token",
            "tokenization failed; monkey=banana; public_key=visible",
        ] {
            assert_eq!(scrub(reason), reason);
        }
        for (text, expected) in [
            (
                "invalid signature; key=VALUE; try again",
                "invalid signature; key=[redacted]; try again",
            ),
            (
                "authentication failed; auth: VALUE; try again",
                "authentication failed; auth: [redacted]; try again",
            ),
            (
                "unsupported token; \"token\": \"VALUE\"; try again",
                "unsupported token; \"token\": [redacted]; try again",
            ),
            (
                "auth = \"VALUE with spaces\"; invalid signature",
                "auth = [redacted]; invalid signature",
            ),
            (
                "authorization: Bearer VALUE; unsupported token",
                "authorization: [redacted]; unsupported token",
            ),
            (
                "signature: {\"r\":\"VALUE\"}; failed",
                "signature: [redacted]; failed",
            ),
            (
                "\u{9519}\u{8bef}: \"auth\": \"\u{79d8}\u{5bc6}\"; failed",
                "\u{9519}\u{8bef}: \"auth\": [redacted]; failed",
            ),
        ] {
            assert_eq!(scrub(text), expected);
        }
    }

    #[test]
    fn diagnostics_keep_reasons_without_credentials_or_request_echoes() {
        let body = serde_json::json!({"error": {"message": "invalid chain"},
            "auth": "SYNTHETIC_SIGNATURE", "request": {"api_key": "SYNTHETIC_KEY"}, "code": 42});
        assert_eq!(summarize(body.to_string().as_bytes()), "invalid chain; 42");
        assert_eq!(
            summarize(b"denied: Bearer SYNTHETIC"),
            "denied: Bearer [redacted]"
        );
        assert_eq!(
            summarize(b"denied: api_key=VALUE"),
            "denied: api_key=[redacted]"
        );
        assert_eq!(summarize(b"{broken auth"), "[invalid JSON body omitted]");
        assert_eq!(summarize(b""), "[no diagnostic body]");
        assert!(!summarize(format!("signature {}", "ab".repeat(65)).as_bytes()).contains("abab"));
        assert!(!summarize("ab".repeat(65).as_bytes()).contains("abab"));
        assert!(summarize("reason ".repeat(300).as_bytes()).ends_with("[truncated]"));
    }
}