r2kit 0.1.0

A safe, ergonomic Rust toolkit for Cloudflare R2 object storage.
Documentation
use crate::ServiceError;

#[derive(Debug)]
enum SafeEvent {
    RemoteFailure {
        operation: &'static str,
        kind: &'static str,
        status: Option<u16>,
    },
    Preflight {
        phase: &'static str,
    },
    ManagedUpload {
        phase: &'static str,
        part_size: u64,
        concurrency: usize,
        max_attempts: u8,
    },
    UploadPartRetry {
        part_number: u16,
        attempt: u8,
        max_attempts: u8,
        delay_ms: u64,
    },
}

pub(crate) fn remote_failure(error: &ServiceError) {
    emit(SafeEvent::RemoteFailure {
        operation: error.operation(),
        kind: error.kind().as_str(),
        status: error.status(),
    });
}

pub(crate) fn preflight(phase: &'static str) {
    emit(SafeEvent::Preflight { phase });
}

pub(crate) fn managed_upload(
    phase: &'static str,
    part_size: u64,
    concurrency: usize,
    max_attempts: u8,
) {
    emit(SafeEvent::ManagedUpload {
        phase,
        part_size,
        concurrency,
        max_attempts,
    });
}

pub(crate) fn upload_part_retry(part_number: u16, attempt: u8, max_attempts: u8, delay_ms: u64) {
    emit(SafeEvent::UploadPartRetry {
        part_number,
        attempt,
        max_attempts,
        delay_ms,
    });
}

#[cfg(feature = "tracing")]
fn emit(event: SafeEvent) {
    match event {
        SafeEvent::RemoteFailure {
            operation,
            kind,
            status,
        } => tracing::warn!(
            target: "r2kit",
            operation,
            error.kind = kind,
            http.status = ?status,
            "R2 request failed"
        ),
        SafeEvent::Preflight { phase } => tracing::debug!(
            target: "r2kit",
            operation = "ListObjectsV2",
            preflight.phase = phase,
            "R2 bucket preflight"
        ),
        SafeEvent::ManagedUpload {
            phase,
            part_size,
            concurrency,
            max_attempts,
        } => tracing::debug!(
            target: "r2kit",
            operation = "ManagedMultipartUpload",
            upload.phase = phase,
            part_size,
            concurrency,
            max_attempts,
            "managed R2 upload"
        ),
        SafeEvent::UploadPartRetry {
            part_number,
            attempt,
            max_attempts,
            delay_ms,
        } => tracing::warn!(
            target: "r2kit",
            operation = "UploadPart",
            part_number,
            attempt,
            max_attempts,
            retry.delay_ms = delay_ms,
            "retrying R2 upload part"
        ),
    }
}

#[cfg(not(feature = "tracing"))]
fn emit(event: SafeEvent) {
    match event {
        SafeEvent::RemoteFailure {
            operation,
            kind,
            status,
        } => {
            let _ = (operation, kind, status);
        }
        SafeEvent::Preflight { phase } => {
            let _ = phase;
        }
        SafeEvent::ManagedUpload {
            phase,
            part_size,
            concurrency,
            max_attempts,
        } => {
            let _ = (phase, part_size, concurrency, max_attempts);
        }
        SafeEvent::UploadPartRetry {
            part_number,
            attempt,
            max_attempts,
            delay_ms,
        } => {
            let _ = (part_number, attempt, max_attempts, delay_ms);
        }
    }
}

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

    #[test]
    fn event_payloads_cannot_carry_sensitive_dimensions() {
        let events = [
            SafeEvent::RemoteFailure {
                operation: "PutObject",
                kind: "permission denied",
                status: Some(403),
            },
            SafeEvent::Preflight { phase: "start" },
            SafeEvent::ManagedUpload {
                phase: "start",
                part_size: 8 * 1024 * 1024,
                concurrency: 4,
                max_attempts: 3,
            },
            SafeEvent::UploadPartRetry {
                part_number: 2,
                attempt: 1,
                max_attempts: 3,
                delay_ms: 264,
            },
        ];
        let fields = events
            .iter()
            .map(|event| format!("{event:?}"))
            .collect::<String>()
            .to_ascii_lowercase();

        for forbidden in [
            "bucket",
            "key",
            "path",
            "account",
            "credential",
            "upload_id",
            "url",
            "header",
        ] {
            assert!(
                !fields.contains(forbidden),
                "unsafe event field: {forbidden}"
            );
        }
        for required in [
            "operation",
            "kind",
            "status",
            "part_size",
            "concurrency",
            "max_attempts",
            "part_number",
            "attempt",
            "delay_ms",
        ] {
            assert!(fields.contains(required), "missing event field: {required}");
        }
    }
}