pray-core 1.9.1

Core library for Prayfile, the package manager for the language placed before inference
Documentation
use crate::{PrayError, PrayResult};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::{Read, Write};

pub const SSH_RPC_SPEC: &str = "pray-ssh-rpc-v1";
pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RpcRequest {
    pub spec: String,
    pub id: String,
    pub method: String,
    #[serde(default)]
    pub params: Value,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RpcResponse {
    pub spec: String,
    pub id: String,
    pub status: u16,
    pub content_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub body_encoding: Option<String>,
    pub body: Value,
}

impl RpcRequest {
    pub fn new(id: impl Into<String>, method: impl Into<String>, params: Value) -> Self {
        Self {
            spec: SSH_RPC_SPEC.to_string(),
            id: id.into(),
            method: method.into(),
            params,
        }
    }
}

impl RpcResponse {
    pub fn json_ok(id: impl Into<String>, body: Value) -> Self {
        Self {
            spec: SSH_RPC_SPEC.to_string(),
            id: id.into(),
            status: 200,
            content_type: "application/json".to_string(),
            body_encoding: None,
            body,
        }
    }

    pub fn binary_ok(id: impl Into<String>, bytes: &[u8]) -> Self {
        Self {
            spec: SSH_RPC_SPEC.to_string(),
            id: id.into(),
            status: 200,
            content_type: "application/octet-stream".to_string(),
            body_encoding: Some("base64".to_string()),
            body: Value::String(STANDARD.encode(bytes)),
        }
    }

    pub fn error(id: impl Into<String>, status: u16, message: impl Into<String>) -> Self {
        Self {
            spec: SSH_RPC_SPEC.to_string(),
            id: id.into(),
            status,
            content_type: "application/json".to_string(),
            body_encoding: None,
            body: serde_json::json!({ "error": message.into() }),
        }
    }

    pub fn decode_body_bytes(&self) -> PrayResult<Vec<u8>> {
        if self.content_type == "application/octet-stream"
            && self.body_encoding.as_deref() == Some("base64")
        {
            let encoded = self.body.as_str().ok_or_else(|| {
                PrayError::Resolution("rpc binary body must be a base64 string".to_string())
            })?;
            STANDARD.decode(encoded).map_err(|error| {
                PrayError::Resolution(format!("rpc binary body base64 decode failed: {error}"))
            })
        } else if self.body.is_string() {
            Ok(self.body.as_str().unwrap_or_default().as_bytes().to_vec())
        } else {
            serde_json::to_vec(&self.body).map_err(|error| PrayError::Manifest(error.to_string()))
        }
    }

    pub fn decode_json_body<T: for<'de> Deserialize<'de>>(&self) -> PrayResult<T> {
        if self.status / 100 != 2 {
            return Err(PrayError::Resolution(format!(
                "rpc {} failed with status {}",
                self.id, self.status
            )));
        }
        serde_json::from_value(self.body.clone()).map_err(|error| PrayError::Parse {
            kind: "ssh rpc response",
            message: error.to_string(),
        })
    }
}

pub fn write_frame(writer: &mut impl Write, payload: &[u8]) -> PrayResult<()> {
    if payload.len() > MAX_FRAME_BYTES {
        return Err(PrayError::Unsupported(format!(
            "rpc frame exceeds maximum size of {MAX_FRAME_BYTES} bytes"
        )));
    }
    let length = u32::try_from(payload.len())
        .map_err(|_| PrayError::Unsupported("rpc frame length overflow".to_string()))?;
    writer
        .write_all(&length.to_be_bytes())
        .map_err(PrayError::from)?;
    writer.write_all(payload).map_err(PrayError::from)?;
    writer.flush().map_err(PrayError::from)?;
    Ok(())
}

pub fn read_frame(reader: &mut impl Read) -> PrayResult<Vec<u8>> {
    let mut length_bytes = [0u8; 4];
    match reader.read_exact(&mut length_bytes) {
        Ok(()) => {}
        Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
            return Err(PrayError::Resolution("rpc stream closed".to_string()));
        }
        Err(error) => return Err(error.into()),
    }
    let length = u32::from_be_bytes(length_bytes) as usize;
    if length > MAX_FRAME_BYTES {
        return Err(PrayError::Unsupported(format!(
            "rpc frame exceeds maximum size of {MAX_FRAME_BYTES} bytes"
        )));
    }
    let mut payload = vec![0u8; length];
    reader.read_exact(&mut payload).map_err(PrayError::from)?;
    Ok(payload)
}

pub fn call_stdio(
    reader: &mut impl Read,
    writer: &mut impl Write,
    request: &RpcRequest,
) -> PrayResult<RpcResponse> {
    let payload =
        serde_json::to_vec(request).map_err(|error| PrayError::Manifest(error.to_string()))?;
    write_frame(writer, &payload)?;
    let response_bytes = read_frame(reader)?;
    serde_json::from_slice(&response_bytes).map_err(|error| PrayError::Parse {
        kind: "ssh rpc response",
        message: error.to_string(),
    })
}

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

    #[test]
    fn frame_round_trip_preserves_payload() {
        let payload = br#"{"spec":"pray-ssh-rpc-v1"}"#;
        let mut buffer = Vec::new();
        write_frame(&mut buffer, payload).expect("write frame");
        let mut cursor = std::io::Cursor::new(buffer);
        let decoded = read_frame(&mut cursor).expect("read frame");
        assert_eq!(decoded, payload);
    }

    #[test]
    fn binary_response_round_trips_base64() {
        let response = RpcResponse::binary_ok("1", b"artifact-bytes");
        let bytes = response.decode_body_bytes().expect("decode bytes");
        assert_eq!(bytes, b"artifact-bytes");
    }
}