sail-rs 0.5.9

Official Rust SDK for Sail: create and drive sailboxes (sandboxed cloud VMs) with lifecycle, streaming exec, file transfer, and ingress.
Documentation
//! Typed client over the secrets and credential-injection-policy HTTP API.
//!
//! Each method validates its input with the shared client-side rules, builds
//! the request, runs it through [`HttpCore`] (retry + Idempotency-Key), maps
//! a non-2xx response onto the canonical [`SailError`] taxonomy, and
//! deserializes the body into a typed struct. These routes live on the same
//! host and speak the same error envelope as the Sailbox lifecycle API.

use serde_json::{json, Value};

use crate::apierror::{is_resource_not_found, raise_api_error};
use crate::error::SailError;
use crate::http::{HttpCore, IdempotencyKey, Method, RequestSpec};
use crate::retry::{RetryPolicy, DEFAULT_RETRY_POLICY, NO_RETRY};

use super::types::{
    validate_policy_name, validate_rules, validate_secret_name, validate_secret_value,
    CredentialInjectionPolicyInfo, CredentialInjectionPolicyPage, InjectionRule,
    ListCredentialInjectionPoliciesQuery, SecretInfo,
};

/// Typed client over the secrets and credential-policy routes.
pub(crate) struct CredentialApi<'a> {
    http: &'a HttpCore,
}

impl<'a> CredentialApi<'a> {
    /// Build a client bound to the sailbox-API `HttpCore`.
    pub(crate) fn new(http: &'a HttpCore) -> CredentialApi<'a> {
        CredentialApi { http }
    }

    // --- secrets ---

    /// Set (create or update) a secret's value (`PUT /v1/secrets/{name}`).
    ///
    /// Naturally idempotent: retries re-apply the same write.
    pub(crate) async fn set_secret(
        &self,
        name: &str,
        value: &str,
    ) -> Result<SecretInfo, SailError> {
        validate_secret_name(name)?;
        validate_secret_value(value)?;
        let (status, data) = self
            .request(
                Method::Put,
                &format!("/v1/secrets/{name}"),
                &[],
                Some(&json!({ "value": value })),
                DEFAULT_RETRY_POLICY,
            )
            .await?;
        raise_api_error(status, &data, &format!("Secret {name:?}"))?;
        secret_from(data)
    }

    /// Fetch one secret's metadata (`GET /v1/secrets/{name}`); never the value.
    pub(crate) async fn get_secret(&self, name: &str) -> Result<SecretInfo, SailError> {
        validate_secret_name(name)?;
        let (status, data) = self
            .request(
                Method::Get,
                &format!("/v1/secrets/{name}"),
                &[],
                /* body */ None,
                DEFAULT_RETRY_POLICY,
            )
            .await?;
        raise_api_error(status, &data, &format!("Secret {name:?}"))?;
        secret_from(data)
    }

    /// List the org's secrets (`GET /v1/secrets`), metadata only. The
    /// response is the complete collection (the service caps secrets per
    /// org), so it is not paginated.
    pub(crate) async fn list_secrets(&self) -> Result<Vec<SecretInfo>, SailError> {
        let (status, data) = self
            .request(
                Method::Get,
                "/v1/secrets",
                &[],
                /* body */ None,
                DEFAULT_RETRY_POLICY,
            )
            .await?;
        raise_api_error(status, &data, "")?;
        let rows = data.get("data").cloned().unwrap_or(Value::Null);
        serde_json::from_value(rows).map_err(|e| SailError::Internal {
            message: format!("failed to parse secrets: {e}"),
        })
    }

    /// Delete a secret (`DELETE /v1/secrets/{name}`).
    ///
    /// Runs without retry so a lost response on a committed delete does not
    /// re-issue the call and surface the already-deleted secret as an error.
    /// A secret referenced by a policy cannot be deleted (409).
    pub(crate) async fn delete_secret(&self, name: &str) -> Result<(), SailError> {
        validate_secret_name(name)?;
        let (status, data) = self
            .request(
                Method::Delete,
                &format!("/v1/secrets/{name}"),
                &[],
                /* body */ None,
                NO_RETRY,
            )
            .await?;
        raise_api_error(status, &data, &format!("Secret {name:?}"))
    }

    // --- credential injection policies ---

    /// Create a policy (`POST /v1/credential-injection-policies`).
    ///
    /// Rules are immutable after creation. The POST carries an auto-minted
    /// `Idempotency-Key`, so a retried create replays the original policy
    /// instead of minting a duplicate.
    pub(crate) async fn create_policy(
        &self,
        name: &str,
        rules: &[InjectionRule],
    ) -> Result<CredentialInjectionPolicyInfo, SailError> {
        validate_policy_name(name)?;
        validate_rules(rules)?;
        let (status, data) = self
            .request(
                Method::Post,
                "/v1/credential-injection-policies",
                &[],
                Some(&json!({ "name": name, "rules": rules })),
                DEFAULT_RETRY_POLICY,
            )
            .await?;
        raise_api_error(status, &data, "")?;
        policy_from(data)
    }

    /// Fetch one policy by id
    /// (`GET /v1/credential-injection-policies/{policy_id}`).
    pub(crate) async fn get_policy(
        &self,
        policy_id: &str,
    ) -> Result<CredentialInjectionPolicyInfo, SailError> {
        require_policy_id(policy_id)?;
        let (status, data) = self
            .request(
                Method::Get,
                &format!("/v1/credential-injection-policies/{policy_id}"),
                &[],
                /* body */ None,
                DEFAULT_RETRY_POLICY,
            )
            .await?;
        raise_api_error(status, &data, &format!("Credential policy {policy_id:?}"))?;
        policy_from(data)
    }

    /// List policies (`GET /v1/credential-injection-policies`) with paging
    /// and an optional name search.
    pub(crate) async fn list_policies(
        &self,
        query: &ListCredentialInjectionPoliciesQuery,
    ) -> Result<CredentialInjectionPolicyPage, SailError> {
        let mut params: Vec<(String, String)> = vec![
            ("limit".to_string(), query.limit.to_string()),
            ("offset".to_string(), query.offset.to_string()),
        ];
        if let Some(search) = &query.search {
            params.push(("search".to_string(), search.clone()));
        }
        let (status, data) = self
            .request(
                Method::Get,
                "/v1/credential-injection-policies",
                &params,
                /* body */ None,
                DEFAULT_RETRY_POLICY,
            )
            .await?;
        raise_api_error(status, &data, "")?;
        serde_json::from_value(data).map_err(|e| SailError::Internal {
            message: format!("failed to parse credential policy page: {e}"),
        })
    }

    /// Rename a policy
    /// (`PATCH /v1/credential-injection-policies/{policy_id}`); the name is
    /// the only mutable field.
    pub(crate) async fn rename_policy(
        &self,
        policy_id: &str,
        name: &str,
    ) -> Result<CredentialInjectionPolicyInfo, SailError> {
        require_policy_id(policy_id)?;
        validate_policy_name(name)?;
        let (status, data) = self
            .request(
                Method::Patch,
                &format!("/v1/credential-injection-policies/{policy_id}"),
                &[],
                Some(&json!({ "name": name })),
                DEFAULT_RETRY_POLICY,
            )
            .await?;
        raise_api_error(status, &data, &format!("Credential policy {policy_id:?}"))?;
        policy_from(data)
    }

    /// Delete a policy
    /// (`DELETE /v1/credential-injection-policies/{policy_id}`).
    ///
    /// Runs without retry so a lost response on a committed delete does not
    /// surface the already-deleted policy as an error. A policy attached to
    /// any Sailbox cannot be deleted (409).
    pub(crate) async fn delete_policy(&self, policy_id: &str) -> Result<(), SailError> {
        require_policy_id(policy_id)?;
        let (status, data) = self
            .request(
                Method::Delete,
                &format!("/v1/credential-injection-policies/{policy_id}"),
                &[],
                /* body */ None,
                NO_RETRY,
            )
            .await?;
        raise_api_error(status, &data, &format!("Credential policy {policy_id:?}"))
    }

    // --- the per-Sailbox attachment slot ---

    /// The policy attached to a Sailbox
    /// (`GET /v1/sailboxes/{id}/credential-injection-policy`), or `None` when
    /// no policy is attached.
    pub(crate) async fn sailbox_policy(
        &self,
        sailbox_id: &str,
    ) -> Result<Option<CredentialInjectionPolicyInfo>, SailError> {
        let (status, data) = self
            .request(
                Method::Get,
                &format!("/v1/sailboxes/{sailbox_id}/credential-injection-policy"),
                &[],
                /* body */ None,
                DEFAULT_RETRY_POLICY,
            )
            .await?;
        // The slot read reports "nothing attached" as a resource-miss 404.
        if status == 404 && is_resource_not_found(&data) {
            return Ok(None);
        }
        raise_api_error(status, &data, &format!("Sailbox {sailbox_id:?}"))?;
        policy_from(data).map(Some)
    }

    /// Attach a policy to a Sailbox
    /// (`PUT /v1/sailboxes/{id}/credential-injection-policy`), replacing any
    /// previously attached policy. Idempotent.
    pub(crate) async fn attach_sailbox_policy(
        &self,
        sailbox_id: &str,
        policy_id: &str,
    ) -> Result<(), SailError> {
        require_policy_id(policy_id)?;
        let (status, data) = self
            .request(
                Method::Put,
                &format!("/v1/sailboxes/{sailbox_id}/credential-injection-policy"),
                &[],
                Some(&json!({ "policy_id": policy_id })),
                DEFAULT_RETRY_POLICY,
            )
            .await?;
        raise_api_error(status, &data, &format!("Sailbox {sailbox_id:?}"))
    }

    /// Detach a Sailbox's policy
    /// (`DELETE /v1/sailboxes/{id}/credential-injection-policy`). Idempotent:
    /// detaching a Sailbox with no policy succeeds.
    pub(crate) async fn detach_sailbox_policy(&self, sailbox_id: &str) -> Result<(), SailError> {
        let (status, data) = self
            .request(
                Method::Delete,
                &format!("/v1/sailboxes/{sailbox_id}/credential-injection-policy"),
                &[],
                /* body */ None,
                DEFAULT_RETRY_POLICY,
            )
            .await?;
        raise_api_error(status, &data, &format!("Sailbox {sailbox_id:?}"))
    }

    // --- transport helper ---

    async fn request(
        &self,
        method: Method,
        path: &str,
        query: &[(String, String)],
        body: Option<&Value>,
        policy: RetryPolicy,
    ) -> Result<(u16, Value), SailError> {
        let body = body
            .map(|value| {
                serde_json::to_vec(value).map_err(|e| SailError::Internal {
                    message: format!("failed to serialize request body: {e}"),
                })
            })
            .transpose()?;
        let spec = RequestSpec {
            method,
            path: path.to_string(),
            query: query.to_vec(),
            body,
            extra_headers: Vec::new(),
            timeout: None,
            policy,
            idempotency_key: IdempotencyKey::Auto,
        };
        self.http.request(&spec).await
    }
}

/// A non-empty policy id, rejected before any network call.
// CR-soon aagam: validate the full `cip_<uuid>` grammar (or percent-encode the
// path segment), and do the same for the sailbox ids interpolated into the
// attach/read/clear paths. A raw id like `../secrets/NAME` is interpolated
// into the URL path today and can normalize to a different resource's
// endpoint.
fn require_policy_id(policy_id: &str) -> Result<(), SailError> {
    if policy_id.trim().is_empty() {
        return Err(SailError::InvalidArgument {
            message: "policy_id is required".to_string(),
        });
    }
    Ok(())
}

fn secret_from(data: Value) -> Result<SecretInfo, SailError> {
    serde_json::from_value(data).map_err(|e| SailError::Internal {
        message: format!("failed to parse secret: {e}"),
    })
}

fn policy_from(data: Value) -> Result<CredentialInjectionPolicyInfo, SailError> {
    serde_json::from_value(data).map_err(|e| SailError::Internal {
        message: format!("failed to parse credential policy: {e}"),
    })
}