parley-md 0.2.0

Reference CLI for the Parley agent-to-agent messaging protocol. Installs the `parley` binary.
//! Client-side blob byte transport (seam B).
//!
//! The server hands the client an **opaque** presigned URL; the client
//! just PUTs/GETs bytes to it. That opacity is what makes the client
//! storage-backend-agnostic — S3, R2, GCS, MinIO, etc. all work
//! unchanged, because the backend choice lives entirely server-side
//! (the `parley_blobs::BlobStore` trait). See docs/post-quantum.md and
//! the blob-architecture notes.
//!
//! This trait abstracts only the transport mechanism (how bytes move),
//! not the URL/backend (what the URL points at). The sole production
//! impl is [`HttpBlobTransport`]; tests inject an in-memory one. We do
//! NOT dispatch on a transport type or carry a discriminator on the
//! wire — if a future non-HTTP transport (IPFS, resumable, …) ever
//! lands, this is the seam to add it, but YAGNI until then.

use anyhow::{Context, Result};
use async_trait::async_trait;

#[async_trait]
pub trait BlobTransport: Send + Sync {
    /// Upload `body` to `url` (a presigned PUT URL).
    async fn put(&self, url: &str, body: Vec<u8>) -> Result<()>;
    /// Download the bytes at `url` (a presigned GET URL).
    async fn get(&self, url: &str) -> Result<Vec<u8>>;
}

/// Default transport: a plain HTTP PUT/GET via `reqwest`. The presigned
/// signature is in the URL's query string, so these requests carry no
/// Parley auth — the object store verifies the URL itself.
#[derive(Debug, Clone)]
pub struct HttpBlobTransport {
    http: reqwest::Client,
}

impl HttpBlobTransport {
    #[must_use]
    pub fn new(http: reqwest::Client) -> Self {
        Self { http }
    }
}

#[async_trait]
impl BlobTransport for HttpBlobTransport {
    async fn put(&self, url: &str, body: Vec<u8>) -> Result<()> {
        let resp = self
            .http
            .put(url)
            .header("content-type", "application/octet-stream")
            .body(body)
            .send()
            .await
            .with_context(|| format!("PUT presigned {url}"))?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            anyhow::bail!("blob PUT returned {status}: {text}");
        }
        Ok(())
    }

    async fn get(&self, url: &str) -> Result<Vec<u8>> {
        let resp = self
            .http
            .get(url)
            .send()
            .await
            .with_context(|| format!("GET presigned {url}"))?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            anyhow::bail!("blob GET returned {status}: {text}");
        }
        Ok(resp.bytes().await?.to_vec())
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use std::collections::HashMap;
    use std::sync::Mutex;

    /// In-memory transport keyed by URL — proves the seam is injectable
    /// without HTTP. (In-process only; the subprocess CLI test uses the
    /// real HttpBlobTransport against the loopback MemoryBlobStore.)
    #[derive(Default)]
    struct MemTransport {
        objects: Mutex<HashMap<String, Vec<u8>>>,
    }

    #[async_trait]
    impl BlobTransport for MemTransport {
        async fn put(&self, url: &str, body: Vec<u8>) -> Result<()> {
            self.objects.lock().unwrap().insert(url.to_owned(), body);
            Ok(())
        }
        async fn get(&self, url: &str) -> Result<Vec<u8>> {
            self.objects
                .lock()
                .unwrap()
                .get(url)
                .cloned()
                .ok_or_else(|| anyhow::anyhow!("not found: {url}"))
        }
    }

    #[tokio::test]
    async fn injectable_transport_round_trips() {
        let t = MemTransport::default();
        t.put("memory://x", b"hello".to_vec()).await.unwrap();
        assert_eq!(t.get("memory://x").await.unwrap(), b"hello");
        assert!(t.get("memory://missing").await.is_err());
    }
}