debugbundle 1.0.0-alpha.1

Safe, bounded runtime error reporting for Rust, Axum and Actix Web
Documentation
#![cfg(feature = "http")]
use debugbundle::transport::HttpTransport;
use std::{
    io::{Read, Write},
    net::TcpListener,
    sync::mpsc,
    time::{Duration, Instant},
};

fn server(
    status: &'static str,
    body: Vec<u8>,
    delay: Duration,
) -> (String, mpsc::Receiver<String>) {
    server_with_headers(status, "", body, delay)
}

fn server_with_headers(
    status: &'static str,
    headers: &'static str,
    body: Vec<u8>,
    delay: Duration,
) -> (String, mpsc::Receiver<String>) {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let address = listener.local_addr().unwrap();
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        let (mut stream, _) = listener.accept().unwrap();
        stream
            .set_read_timeout(Some(Duration::from_secs(2)))
            .unwrap();
        let mut buffer = vec![0; 16384];
        let n = stream.read(&mut buffer).unwrap();
        let _ = tx.send(String::from_utf8_lossy(&buffer[..n]).into_owned());
        std::thread::sleep(delay);
        let header=format!("HTTP/1.1 {status}\r\n{headers}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",body.len());
        let _ = stream.write_all(header.as_bytes());
        let _ = stream.write_all(&body);
    });
    (format!("http://{address}/v1/events"), rx)
}

#[tokio::test]
async fn native_transport_authenticates_and_requires_a_bounded_json_response() {
    let (endpoint, request) = server(
        "202 Accepted",
        br#"{"accepted":1,"rejected":0,"errors":[]}"#.to_vec(),
        Duration::ZERO,
    );
    let transport = HttpTransport::new(
        &endpoint,
        "synthetic-project-token",
        Duration::from_secs(1),
        true,
        None,
    )
    .unwrap();
    let reply = transport
        .send(br#"{"events":[{}]}"#.to_vec())
        .await
        .unwrap();
    assert_eq!(reply.status, 202);
    assert_eq!(reply.body["accepted"], 1);
    let request = request
        .recv_timeout(Duration::from_secs(1))
        .unwrap()
        .to_lowercase();
    assert!(request.contains("authorization: bearer synthetic-project-token"));
    assert!(request.contains("x-debugbundle-evidence: 1"));
}

#[tokio::test]
async fn oversized_and_stalled_responses_fail_without_unbounded_reads() {
    let (endpoint, _) = server("202 Accepted", vec![b' '; 262145], Duration::ZERO);
    let transport =
        HttpTransport::new(&endpoint, "synthetic", Duration::from_secs(1), true, None).unwrap();
    assert!(transport.send(br#"{"events":[]}"#.to_vec()).await.is_err());
    let (endpoint, _) = server("202 Accepted", b"{}".to_vec(), Duration::from_millis(500));
    let transport = HttpTransport::new(
        &endpoint,
        "synthetic",
        Duration::from_millis(50),
        true,
        None,
    )
    .unwrap();
    let started = Instant::now();
    assert!(transport.send(br#"{"events":[]}"#.to_vec()).await.is_err());
    assert!(started.elapsed() < Duration::from_secs(1));
}

#[test]
fn insecure_endpoints_and_url_credentials_are_not_production_defaults() {
    for endpoint in [
        "http://example.test/v1/events",
        "https://user:secret@example.test/v1/events",
        "file:///tmp/events",
        "https://example.test/#secret",
    ] {
        assert!(
            HttpTransport::new(endpoint, "synthetic", Duration::from_secs(1), false, None)
                .is_none()
        );
    }
}

#[tokio::test]
async fn low_level_transport_rejects_credential_queries_and_discovery_identity() {
    assert!(HttpTransport::new(
        "https://api.example.test/v1/events?token=SYNTHETIC_SECRET",
        "synthetic",
        Duration::from_secs(1),
        false,
        None
    )
    .is_none());
    let transport = HttpTransport::new(
        "https://api.example.test/v1/events?mode=a%20b",
        "synthetic",
        Duration::from_secs(1),
        false,
        None,
    )
    .unwrap();
    assert!(transport
        .config(None, "dbundle_proj_SYNTHETIC_SECRET", "test")
        .await
        .is_err());
}

#[tokio::test]
async fn application_gzip_feature_cannot_bypass_the_sdk_response_encoding_boundary() {
    use base64::{engine::general_purpose::STANDARD, Engine};
    let compressed = STANDARD
        .decode("H4sIAAAAAAAC/6tWSkxOTi0oSU1RsjLUUSpKzUpNBnMMdJRSi4ryi4qVrKJjawHEPoRqJwAAAA==")
        .unwrap();
    // This real response proves Cargo has enabled automatic decompression in
    // the application's reqwest. SDK requests must still reject these bytes.
    let (endpoint, _) = server_with_headers(
        "202 Accepted",
        "Content-Encoding: gzip\r\n",
        compressed.clone(),
        Duration::ZERO,
    );
    let control = reqwest::Client::builder()
        .no_proxy()
        .build()
        .unwrap()
        .get(endpoint)
        .send()
        .await
        .unwrap()
        .json::<serde_json::Value>()
        .await
        .unwrap();
    assert_eq!(control["accepted"], 1);
    for config_request in [false, true] {
        let (endpoint, request) = server_with_headers(
            "202 Accepted",
            "Content-Encoding: gzip\r\n",
            compressed.clone(),
            Duration::ZERO,
        );
        let transport =
            HttpTransport::new(&endpoint, "synthetic", Duration::from_secs(1), true, None).unwrap();
        let reply = if config_request {
            transport.config(None, "checkout", "test").await
        } else {
            transport.send(br#"{"events":[]}"#.to_vec()).await
        };
        assert!(
            reply.is_err(),
            "compressed response bypassed the SDK boundary"
        );
        assert!(request
            .recv_timeout(Duration::from_secs(1))
            .unwrap()
            .to_lowercase()
            .contains("accept-encoding: identity"));
    }
}