un-inception/clients/rust/async/src/lib.rs
russell@unturf.com 6d91e987ce feat: Full SDK implementations for 8 languages (sync + async)
Complete SDK implementations with full API surface:
- Python: sync/async with all 43 functions
- Go: sync/async with full API
- Java: sync/async with full API
- JavaScript: sync/async with full API
- PHP: sync/async with full API
- Ruby: sync/async with full API
- Rust: sync/async with full API

Added:
- clients/README.md - SDK overview
- clients/CLI_SPEC.md - CLI specification

Removed:
- un_deno.ts (moved to clients/typescript/)
2026-01-15 18:35:43 -05:00

1949 lines
58 KiB
Rust

// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
//
// unsandbox.com Rust SDK (Asynchronous)
//
// Library Usage:
// use un_async::{Credentials, execute_code, resolve_credentials};
//
// #[tokio::main]
// async fn main() -> Result<(), un_async::UnsandboxError> {
// // Resolve credentials (4-tier priority)
// let creds = resolve_credentials(None, None)?;
//
// // Execute code asynchronously
// let result = execute_code("python", r#"print("hello")"#, &creds).await?;
// println!("Output: {}", result.output);
//
// // Execute async (returns job_id immediately)
// let job_id = execute_async("javascript", r#"console.log("hello")"#, &creds).await?;
//
// // Wait for job completion
// let result = wait_for_job(&job_id, &creds, None).await?;
//
// // List all jobs
// let jobs = list_jobs(&creds).await?;
//
// // Get supported languages (cached 1 hour)
// let languages = get_languages(&creds).await?;
//
// // Detect language from filename (sync, no network)
// let lang = detect_language("script.py"); // Some("python")
//
// // Snapshot operations
// let snapshot = session_snapshot(&session_id, &creds, Some("my_snapshot"), false).await?;
// let snapshots = list_snapshots(&creds).await?;
// let result = restore_snapshot(&snapshot_id, &creds).await?;
// delete_snapshot(&snapshot_id, &creds).await?;
//
// Ok(())
// }
//
// Authentication Priority (4-tier):
// 1. Function arguments (public_key, secret_key)
// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
// 3. Config file (~/.unsandbox/accounts.csv, line 0 by default)
// 4. Local directory (./accounts.csv, line 0 by default)
//
// Format: public_key,secret_key (one per line)
// Account selection: UNSANDBOX_ACCOUNT=N env var (0-based index)
//
// Request Authentication (HMAC-SHA256):
// Authorization: Bearer <public_key> (identifies account)
// X-Timestamp: <unix_seconds> (replay prevention)
// X-Signature: HMAC-SHA256(secret_key, msg) (proves secret + body integrity)
//
// Message format: "timestamp:METHOD:path:body"
// - timestamp: seconds since epoch
// - METHOD: GET, POST, DELETE, etc. (uppercase)
// - path: e.g., "/execute", "/jobs/123"
// - body: JSON payload (empty string for GET/DELETE)
//
// Languages Cache:
// - Cached in ~/.unsandbox/languages.json
// - TTL: 1 hour
// - Updated on successful API calls
use hmac::{Hmac, Mac};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::fs;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::time::sleep;
/// API base URL
const API_BASE: &str = "https://api.unsandbox.com";
/// Languages cache TTL in seconds (1 hour)
const LANGUAGES_CACHE_TTL: u64 = 3600;
/// Polling delays in milliseconds for exponential backoff
const POLL_DELAYS_MS: &[u64] = &[300, 450, 700, 900, 650, 1600, 2000];
/// Default timeout for wait_for_job in seconds
const DEFAULT_TIMEOUT_SECS: u64 = 300;
type HmacSha256 = Hmac<Sha256>;
// =============================================================================
// Error Types
// =============================================================================
/// Error type for unsandbox SDK operations
#[derive(Debug, thiserror::Error)]
pub enum UnsandboxError {
/// No credentials found in any of the 4 tiers
#[error("No credentials found. Please provide via:\n 1. Function arguments (public_key, secret_key)\n 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n 3. ~/.unsandbox/accounts.csv\n 4. ./accounts.csv")]
NoCredentials,
/// HTTP request failed
#[error("HTTP request failed: {0}")]
HttpError(#[from] reqwest::Error),
/// API returned an error response
#[error("API error (HTTP {status}): {message}")]
ApiError { status: u16, message: String },
/// JSON serialization/deserialization failed
#[error("JSON error: {0}")]
JsonError(#[from] serde_json::Error),
/// I/O error (file operations)
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
/// Job timed out while waiting
#[error("Job timed out after {0} seconds")]
Timeout(u64),
/// Missing expected field in response
#[error("Missing field in response: {0}")]
MissingField(String),
}
/// Result type for unsandbox SDK operations
pub type Result<T> = std::result::Result<T, UnsandboxError>;
// =============================================================================
// Credentials
// =============================================================================
/// API credentials for authentication
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credentials {
/// Public key (unsb-pk-xxxx-xxxx-xxxx-xxxx) - used as Bearer token
pub public_key: String,
/// Secret key (unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx) - used for HMAC signing, never transmitted
pub secret_key: String,
}
impl Credentials {
/// Create new credentials from public and secret keys
pub fn new(public_key: impl Into<String>, secret_key: impl Into<String>) -> Self {
Self {
public_key: public_key.into(),
secret_key: secret_key.into(),
}
}
}
// =============================================================================
// Response Types
// =============================================================================
/// Result of code execution
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecuteResult {
/// Job ID
pub job_id: String,
/// Execution status: "completed", "failed", "timeout", "cancelled"
pub status: String,
/// Combined stdout/stderr output
#[serde(default)]
pub output: String,
/// Exit code (0 = success)
#[serde(default)]
pub exit_code: i32,
/// Execution time in milliseconds
#[serde(default)]
pub execution_time_ms: u64,
}
/// Job status from /jobs/{id} endpoint
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobStatus {
/// Job ID
pub job_id: String,
/// Current status: "pending", "running", "completed", "failed", "timeout", "cancelled"
pub status: String,
/// Language used
#[serde(default)]
pub language: String,
/// Combined output (available when completed)
#[serde(default)]
pub output: String,
/// Exit code (available when completed)
#[serde(default)]
pub exit_code: i32,
/// Execution time in milliseconds
#[serde(default)]
pub execution_time_ms: u64,
/// Created timestamp
#[serde(default)]
pub created_at: String,
}
/// Job summary from /jobs list endpoint
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Job {
/// Job ID
pub job_id: String,
/// Current status
pub status: String,
/// Language used
#[serde(default)]
pub language: String,
/// Created timestamp
#[serde(default)]
pub created_at: String,
}
/// Snapshot information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
/// Snapshot ID
pub snapshot_id: String,
/// Snapshot name
#[serde(default)]
pub name: String,
/// Source type: "session" or "service"
#[serde(default)]
pub source_type: String,
/// Source ID (session_id or service_id)
#[serde(default)]
pub source_id: String,
/// Whether this is a hot (ephemeral) snapshot
#[serde(default)]
pub hot: bool,
/// Created timestamp
#[serde(default)]
pub created_at: String,
/// Size in bytes
#[serde(default)]
pub size_bytes: u64,
}
/// Result of restoring a snapshot
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestoreResult {
/// New session or service ID
pub id: String,
/// Type: "session" or "service"
#[serde(rename = "type")]
pub restore_type: String,
/// Status message
#[serde(default)]
pub message: String,
}
/// Session information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
/// Session ID
pub session_id: String,
/// Container name (e.g., "unsb-vm-abc123")
#[serde(default)]
pub container_name: String,
/// Session status: "running", "frozen", "stopped"
#[serde(default)]
pub status: String,
/// Network mode: "zerotrust" or "semitrusted"
#[serde(default)]
pub network_mode: String,
/// Shell type (e.g., "bash", "python3")
#[serde(default)]
pub shell: String,
/// Number of vCPUs
#[serde(default)]
pub vcpu: u32,
/// Memory in MB
#[serde(default)]
pub memory_mb: u32,
/// Whether the session is boosted
#[serde(default)]
pub boosted: bool,
/// Created timestamp
#[serde(default)]
pub created_at: String,
/// Last activity timestamp
#[serde(default)]
pub last_activity: String,
}
/// Service information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Service {
/// Service ID
pub service_id: String,
/// Service name
#[serde(default)]
pub name: String,
/// Container name
#[serde(default)]
pub container_name: String,
/// Service status: "running", "frozen", "stopped", "locked"
#[serde(default)]
pub status: String,
/// Exposed ports
#[serde(default)]
pub ports: Vec<u16>,
/// Custom domains
#[serde(default)]
pub domains: Vec<String>,
/// Network mode
#[serde(default)]
pub network_mode: String,
/// Number of vCPUs
#[serde(default)]
pub vcpu: u32,
/// Memory in MB
#[serde(default)]
pub memory_mb: u32,
/// Whether the service is locked (cannot be modified)
#[serde(default)]
pub locked: bool,
/// Public URL for the service
#[serde(default)]
pub url: String,
/// Created timestamp
#[serde(default)]
pub created_at: String,
}
/// Result of shell command execution in a session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellResult {
/// Command output (stdout + stderr)
#[serde(default)]
pub output: String,
/// Exit code
#[serde(default)]
pub exit_code: i32,
}
/// Result of validating API keys
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeysValid {
/// Whether the keys are valid
pub valid: bool,
/// Account ID associated with the keys
#[serde(default)]
pub account_id: String,
/// Account email (if available)
#[serde(default)]
pub email: String,
/// Account plan/tier
#[serde(default)]
pub plan: String,
/// Error message if invalid
#[serde(default)]
pub error: String,
}
/// Options for creating a session
#[derive(Debug, Clone, Default)]
pub struct SessionCreateOptions {
/// Network mode: "zerotrust" (default) or "semitrusted"
pub network_mode: Option<String>,
/// Shell to use (e.g., "bash", "python3")
pub shell: Option<String>,
/// Number of vCPUs (default: 1)
pub vcpu: Option<u32>,
/// Whether to use tmux multiplexer
pub tmux: Option<bool>,
/// Whether to use screen multiplexer
pub screen: Option<bool>,
}
/// Options for creating a service
#[derive(Debug, Clone, Default)]
pub struct ServiceCreateOptions {
/// Network mode: "zerotrust" (default) or "semitrusted"
pub network_mode: Option<String>,
/// Number of vCPUs (default: 1)
pub vcpu: Option<u32>,
/// Custom domains for the service
pub domains: Option<Vec<String>>,
/// Bootstrap script content
pub bootstrap: Option<String>,
/// Bootstrap script URL
pub bootstrap_url: Option<String>,
}
/// Options for updating a service
#[derive(Debug, Clone, Default)]
pub struct ServiceUpdateOptions {
/// New service name
pub name: Option<String>,
/// New ports
pub ports: Option<Vec<u16>>,
/// New domains
pub domains: Option<Vec<String>>,
/// New vCPU count
pub vcpu: Option<u32>,
}
/// Options for AI image generation
#[derive(Debug, Clone, Default)]
pub struct ImageOptions {
/// Model to use (optional)
pub model: Option<String>,
/// Image size (default: "1024x1024")
pub size: Option<String>,
/// Quality: "standard" or "hd" (default: "standard")
pub quality: Option<String>,
/// Number of images to generate (default: 1)
pub n: Option<i32>,
}
/// Result of image generation
#[derive(Debug, Clone, Deserialize)]
pub struct ImageResult {
/// Generated images (base64 or URLs)
pub images: Vec<String>,
/// Timestamp when images were created
pub created_at: String,
}
// =============================================================================
// Internal Response Types
// =============================================================================
#[derive(Debug, Deserialize)]
struct ExecuteResponse {
job_id: String,
status: String,
#[serde(default)]
output: String,
#[serde(default)]
exit_code: i32,
#[serde(default)]
execution_time_ms: u64,
}
#[derive(Debug, Deserialize)]
struct JobsListResponse {
jobs: Vec<Job>,
}
#[derive(Debug, Deserialize)]
struct LanguagesResponse {
languages: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct SnapshotsListResponse {
snapshots: Vec<Snapshot>,
}
#[derive(Debug, Deserialize)]
struct SnapshotCreateResponse {
snapshot_id: String,
#[serde(default)]
name: String,
#[serde(default)]
source_type: String,
#[serde(default)]
source_id: String,
#[serde(default)]
hot: bool,
#[serde(default)]
created_at: String,
#[serde(default)]
size_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct LanguagesCache {
languages: Vec<String>,
timestamp: u64,
}
#[derive(Debug, Deserialize)]
struct SessionsListResponse {
sessions: Vec<Session>,
}
#[derive(Debug, Deserialize)]
struct ServicesListResponse {
services: Vec<Service>,
}
#[derive(Debug, Deserialize)]
struct EnvResponse {
env: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct EnvExportResponse {
content: String,
}
// =============================================================================
// Language Detection
// =============================================================================
/// Language extension mapping
fn get_language_map() -> HashMap<&'static str, &'static str> {
[
("py", "python"),
("js", "javascript"),
("ts", "typescript"),
("rb", "ruby"),
("php", "php"),
("pl", "perl"),
("sh", "bash"),
("r", "r"),
("lua", "lua"),
("go", "go"),
("rs", "rust"),
("c", "c"),
("cpp", "cpp"),
("cc", "cpp"),
("cxx", "cpp"),
("java", "java"),
("kt", "kotlin"),
("m", "objc"),
("cs", "csharp"),
("fs", "fsharp"),
("hs", "haskell"),
("ml", "ocaml"),
("clj", "clojure"),
("scm", "scheme"),
("ss", "scheme"),
("erl", "erlang"),
("ex", "elixir"),
("exs", "elixir"),
("jl", "julia"),
("d", "d"),
("nim", "nim"),
("zig", "zig"),
("v", "v"),
("cr", "crystal"),
("dart", "dart"),
("groovy", "groovy"),
("f90", "fortran"),
("f95", "fortran"),
("lisp", "commonlisp"),
("lsp", "commonlisp"),
("cob", "cobol"),
("tcl", "tcl"),
("raku", "raku"),
("pro", "prolog"),
("p", "prolog"),
("4th", "forth"),
("forth", "forth"),
("fth", "forth"),
]
.into_iter()
.collect()
}
/// Detect programming language from filename extension.
///
/// This function is synchronous (no network call needed).
///
/// # Arguments
/// * `filename` - Filename to detect language from (e.g., "script.py")
///
/// # Returns
/// Language identifier (e.g., "python") or None if unknown
///
/// # Examples
/// ```
/// use un_async::detect_language;
///
/// assert_eq!(detect_language("hello.py"), Some("python"));
/// assert_eq!(detect_language("script.js"), Some("javascript"));
/// assert_eq!(detect_language("main.go"), Some("go"));
/// assert_eq!(detect_language("unknown"), None);
/// ```
pub fn detect_language(filename: &str) -> Option<&'static str> {
let ext = filename.rsplit('.').next()?;
if ext == filename {
return None; // No extension found
}
let ext_lower = ext.to_lowercase();
get_language_map().get(ext_lower.as_str()).copied()
}
// =============================================================================
// Credentials Resolution
// =============================================================================
/// Get the ~/.unsandbox directory path
fn get_unsandbox_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".unsandbox"))
}
/// Ensure ~/.unsandbox directory exists
async fn ensure_unsandbox_dir() -> Option<PathBuf> {
let dir = get_unsandbox_dir()?;
fs::create_dir_all(&dir).await.ok()?;
Some(dir)
}
/// Load credentials from a CSV file (public_key,secret_key per line) - async version
async fn load_credentials_from_csv_async(path: &PathBuf, account_index: usize) -> Option<Credentials> {
let file = fs::File::open(path).await.ok()?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
let mut current_index = 0;
while let Ok(Some(line)) = lines.next_line().await {
let line = line.trim().to_string();
if line.is_empty() || line.starts_with('#') {
continue;
}
if current_index == account_index {
let parts: Vec<&str> = line.split(',').collect();
if parts.len() >= 2 {
let pk = parts[0].trim();
let sk = parts[1].trim();
if pk.starts_with("unsb-pk-") && sk.starts_with("unsb-sk-") {
return Some(Credentials::new(pk, sk));
}
}
}
current_index += 1;
}
None
}
/// Load credentials from a CSV file (public_key,secret_key per line) - sync version for resolve_credentials
fn load_credentials_from_csv_sync(path: &PathBuf, account_index: usize) -> Option<Credentials> {
use std::io::{BufRead, BufReader};
let file = std::fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let mut current_index = 0;
for line in reader.lines().map_while(|l| l.ok()) {
let line = line.trim().to_string();
if line.is_empty() || line.starts_with('#') {
continue;
}
if current_index == account_index {
let parts: Vec<&str> = line.split(',').collect();
if parts.len() >= 2 {
let pk = parts[0].trim();
let sk = parts[1].trim();
if pk.starts_with("unsb-pk-") && sk.starts_with("unsb-sk-") {
return Some(Credentials::new(pk, sk));
}
}
}
current_index += 1;
}
None
}
/// Resolve credentials using 4-tier priority system.
///
/// This function is synchronous since it only reads files and env vars.
///
/// # Priority
/// 1. Function arguments (if both provided)
/// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
/// 3. ~/.unsandbox/accounts.csv
/// 4. ./accounts.csv
///
/// # Arguments
/// * `public_key` - Optional public key from function argument
/// * `secret_key` - Optional secret key from function argument
///
/// # Returns
/// Credentials if found, UnsandboxError::NoCredentials otherwise
///
/// # Examples
/// ```ignore
/// // Use environment variables or config file
/// let creds = resolve_credentials(None, None)?;
///
/// // Use explicit credentials
/// let creds = resolve_credentials(
/// Some("unsb-pk-xxxx"),
/// Some("unsb-sk-xxxx")
/// )?;
/// ```
pub fn resolve_credentials(
public_key: Option<&str>,
secret_key: Option<&str>,
) -> Result<Credentials> {
// Tier 1: Function arguments
if let (Some(pk), Some(sk)) = (public_key, secret_key) {
if !pk.is_empty() && !sk.is_empty() {
return Ok(Credentials::new(pk, sk));
}
}
// Tier 2: Environment variables
let env_pk = env::var("UNSANDBOX_PUBLIC_KEY").ok();
let env_sk = env::var("UNSANDBOX_SECRET_KEY").ok();
if let (Some(pk), Some(sk)) = (env_pk, env_sk) {
if !pk.is_empty() && !sk.is_empty() {
return Ok(Credentials::new(pk, sk));
}
}
// Determine account index
let account_index: usize = env::var("UNSANDBOX_ACCOUNT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// Tier 3: ~/.unsandbox/accounts.csv
if let Some(dir) = get_unsandbox_dir() {
let csv_path = dir.join("accounts.csv");
if let Some(creds) = load_credentials_from_csv_sync(&csv_path, account_index) {
return Ok(creds);
}
}
// Tier 4: ./accounts.csv
let local_csv = PathBuf::from("accounts.csv");
if let Some(creds) = load_credentials_from_csv_sync(&local_csv, account_index) {
return Ok(creds);
}
Err(UnsandboxError::NoCredentials)
}
/// Async version of resolve_credentials
pub async fn resolve_credentials_async(
public_key: Option<&str>,
secret_key: Option<&str>,
) -> Result<Credentials> {
// Tier 1: Function arguments
if let (Some(pk), Some(sk)) = (public_key, secret_key) {
if !pk.is_empty() && !sk.is_empty() {
return Ok(Credentials::new(pk, sk));
}
}
// Tier 2: Environment variables
let env_pk = env::var("UNSANDBOX_PUBLIC_KEY").ok();
let env_sk = env::var("UNSANDBOX_SECRET_KEY").ok();
if let (Some(pk), Some(sk)) = (env_pk, env_sk) {
if !pk.is_empty() && !sk.is_empty() {
return Ok(Credentials::new(pk, sk));
}
}
// Determine account index
let account_index: usize = env::var("UNSANDBOX_ACCOUNT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// Tier 3: ~/.unsandbox/accounts.csv
if let Some(dir) = get_unsandbox_dir() {
let csv_path = dir.join("accounts.csv");
if let Some(creds) = load_credentials_from_csv_async(&csv_path, account_index).await {
return Ok(creds);
}
}
// Tier 4: ./accounts.csv
let local_csv = PathBuf::from("accounts.csv");
if let Some(creds) = load_credentials_from_csv_async(&local_csv, account_index).await {
return Ok(creds);
}
Err(UnsandboxError::NoCredentials)
}
// =============================================================================
// HMAC Signing
// =============================================================================
/// Sign a request using HMAC-SHA256.
///
/// Message format: "timestamp:METHOD:path:body"
fn sign_request(secret_key: &str, timestamp: u64, method: &str, path: &str, body: &str) -> String {
let message = format!("{}:{}:{}:{}", timestamp, method, path, body);
let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes())
.expect("HMAC can take key of any size");
mac.update(message.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
/// Get current Unix timestamp in seconds
fn get_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs()
}
// =============================================================================
// HTTP Client
// =============================================================================
/// Make an authenticated HTTP request to the API
async fn make_request<T: for<'de> Deserialize<'de>>(
method: &str,
path: &str,
creds: &Credentials,
body: Option<&impl Serialize>,
) -> Result<T> {
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()?;
let url = format!("{}{}", API_BASE, path);
let timestamp = get_timestamp();
let body_str = match body {
Some(b) => serde_json::to_string(b)?,
None => String::new(),
};
let signature = sign_request(&creds.secret_key, timestamp, method, path, &body_str);
let mut request = match method {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PATCH" => client.patch(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
_ => client.get(&url),
};
request = request
.header("Authorization", format!("Bearer {}", creds.public_key))
.header("X-Timestamp", timestamp.to_string())
.header("X-Signature", signature)
.header("Content-Type", "application/json")
.header("User-Agent", "un-rust-async/2.0");
if !body_str.is_empty() {
request = request.body(body_str);
}
let response = request.send().await?;
let status = response.status().as_u16();
let response_text = response.text().await?;
if status < 200 || status >= 300 {
return Err(UnsandboxError::ApiError {
status,
message: response_text,
});
}
let result: T = serde_json::from_str(&response_text)?;
Ok(result)
}
// =============================================================================
// Languages Cache
// =============================================================================
/// Get path to languages cache file
fn get_languages_cache_path() -> Option<PathBuf> {
get_unsandbox_dir().map(|d| d.join("languages.json"))
}
/// Load languages from cache if valid (< 1 hour old)
async fn load_languages_cache() -> Option<Vec<String>> {
let cache_path = get_languages_cache_path()?;
let content = fs::read_to_string(&cache_path).await.ok()?;
let cache: LanguagesCache = serde_json::from_str(&content).ok()?;
let now = get_timestamp();
if now - cache.timestamp < LANGUAGES_CACHE_TTL {
Some(cache.languages)
} else {
None
}
}
/// Save languages to cache
async fn save_languages_cache(languages: &[String]) {
if let Some(cache_path) = get_languages_cache_path() {
let _ = ensure_unsandbox_dir().await;
let cache = LanguagesCache {
languages: languages.to_vec(),
timestamp: get_timestamp(),
};
if let Ok(content) = serde_json::to_string_pretty(&cache) {
let _ = fs::write(cache_path, content).await;
}
}
}
// =============================================================================
// Public API Functions
// =============================================================================
/// Execute code and wait for completion.
///
/// # Arguments
/// * `language` - Programming language (e.g., "python", "javascript")
/// * `code` - Source code to execute
/// * `creds` - API credentials
///
/// # Returns
/// ExecuteResult with output and exit code
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let result = execute_code("python", r#"print("Hello, World!")"#, &creds).await?;
/// println!("Output: {}", result.output);
/// println!("Exit code: {}", result.exit_code);
/// ```
pub async fn execute_code(language: &str, code: &str, creds: &Credentials) -> Result<ExecuteResult> {
let body = serde_json::json!({
"language": language,
"code": code
});
let response: ExecuteResponse = make_request("POST", "/execute", creds, Some(&body)).await?;
// If job is still pending/running, poll until completion
if response.status == "pending" || response.status == "running" {
return wait_for_job(&response.job_id, creds, None).await;
}
Ok(ExecuteResult {
job_id: response.job_id,
status: response.status,
output: response.output,
exit_code: response.exit_code,
execution_time_ms: response.execution_time_ms,
})
}
/// Execute code asynchronously (returns immediately with job_id).
///
/// # Arguments
/// * `language` - Programming language
/// * `code` - Source code to execute
/// * `creds` - API credentials
///
/// # Returns
/// Job ID string for polling
///
/// # Examples
/// ```ignore
/// let job_id = execute_async("python", "import time; time.sleep(5); print('done')", &creds).await?;
/// // Do other async work...
/// let result = wait_for_job(&job_id, &creds, None).await?;
/// ```
pub async fn execute_async(language: &str, code: &str, creds: &Credentials) -> Result<String> {
let body = serde_json::json!({
"language": language,
"code": code
});
let response: ExecuteResponse = make_request("POST", "/execute", creds, Some(&body)).await?;
Ok(response.job_id)
}
/// Get current status/result of a job (single poll, no waiting).
///
/// # Arguments
/// * `job_id` - Job ID from execute_async
/// * `creds` - API credentials
///
/// # Returns
/// JobStatus with current state
pub async fn get_job(job_id: &str, creds: &Credentials) -> Result<JobStatus> {
let path = format!("/jobs/{}", job_id);
make_request("GET", &path, creds, None::<&()>).await
}
/// Wait for job completion with exponential backoff polling.
///
/// Polling delays (ms): [300, 450, 700, 900, 650, 1600, 2000, ...]
///
/// # Arguments
/// * `job_id` - Job ID from execute_async
/// * `creds` - API credentials
/// * `timeout` - Optional timeout in seconds (default: 300)
///
/// # Returns
/// ExecuteResult when job completes
///
/// # Errors
/// Returns UnsandboxError::Timeout if job doesn't complete within timeout
pub async fn wait_for_job(
job_id: &str,
creds: &Credentials,
timeout: Option<u64>,
) -> Result<ExecuteResult> {
let timeout_secs = timeout.unwrap_or(DEFAULT_TIMEOUT_SECS);
let start = std::time::Instant::now();
let mut poll_count = 0;
loop {
// Check timeout
if start.elapsed().as_secs() >= timeout_secs {
return Err(UnsandboxError::Timeout(timeout_secs));
}
// Sleep before polling (async)
let delay_idx = poll_count.min(POLL_DELAYS_MS.len() - 1);
sleep(Duration::from_millis(POLL_DELAYS_MS[delay_idx])).await;
poll_count += 1;
let status = get_job(job_id, creds).await?;
match status.status.as_str() {
"completed" | "failed" | "timeout" | "cancelled" => {
return Ok(ExecuteResult {
job_id: status.job_id,
status: status.status,
output: status.output,
exit_code: status.exit_code,
execution_time_ms: status.execution_time_ms,
});
}
_ => continue, // Still running, continue polling
}
}
}
/// Cancel a running job.
///
/// # Arguments
/// * `job_id` - Job ID to cancel
/// * `creds` - API credentials
pub async fn cancel_job(job_id: &str, creds: &Credentials) -> Result<()> {
let path = format!("/jobs/{}", job_id);
let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>).await?;
Ok(())
}
/// List all jobs for the authenticated account.
///
/// # Arguments
/// * `creds` - API credentials
///
/// # Returns
/// Vector of Job summaries
pub async fn list_jobs(creds: &Credentials) -> Result<Vec<Job>> {
let response: JobsListResponse = make_request("GET", "/jobs", creds, None::<&()>).await?;
Ok(response.jobs)
}
/// Get list of supported programming languages.
///
/// Results are cached for 1 hour in ~/.unsandbox/languages.json
///
/// # Arguments
/// * `creds` - API credentials
///
/// # Returns
/// Vector of language identifiers
pub async fn get_languages(creds: &Credentials) -> Result<Vec<String>> {
// Try cache first
if let Some(cached) = load_languages_cache().await {
return Ok(cached);
}
let response: LanguagesResponse = make_request("GET", "/languages", creds, None::<&()>).await?;
// Cache the result
save_languages_cache(&response.languages).await;
Ok(response.languages)
}
/// Create a snapshot of a session.
///
/// # Arguments
/// * `session_id` - Session ID to snapshot
/// * `creds` - API credentials
/// * `name` - Optional snapshot name
/// * `ephemeral` - If true, create a hot (ephemeral) snapshot
///
/// # Returns
/// Snapshot information
pub async fn session_snapshot(
session_id: &str,
creds: &Credentials,
name: Option<&str>,
ephemeral: bool,
) -> Result<Snapshot> {
let mut body = serde_json::json!({
"session_id": session_id,
"hot": ephemeral
});
if let Some(n) = name {
body["name"] = serde_json::json!(n);
}
let response: SnapshotCreateResponse = make_request("POST", "/snapshots", creds, Some(&body)).await?;
Ok(Snapshot {
snapshot_id: response.snapshot_id,
name: response.name,
source_type: response.source_type,
source_id: response.source_id,
hot: response.hot,
created_at: response.created_at,
size_bytes: response.size_bytes,
})
}
/// Create a snapshot of a service.
///
/// # Arguments
/// * `service_id` - Service ID to snapshot
/// * `creds` - API credentials
/// * `name` - Optional snapshot name
///
/// # Returns
/// Snapshot information
pub async fn service_snapshot(
service_id: &str,
creds: &Credentials,
name: Option<&str>,
) -> Result<Snapshot> {
let mut body = serde_json::json!({
"service_id": service_id,
"hot": false
});
if let Some(n) = name {
body["name"] = serde_json::json!(n);
}
let response: SnapshotCreateResponse = make_request("POST", "/snapshots", creds, Some(&body)).await?;
Ok(Snapshot {
snapshot_id: response.snapshot_id,
name: response.name,
source_type: response.source_type,
source_id: response.source_id,
hot: response.hot,
created_at: response.created_at,
size_bytes: response.size_bytes,
})
}
/// List all snapshots for the authenticated account.
///
/// # Arguments
/// * `creds` - API credentials
///
/// # Returns
/// Vector of Snapshot information
pub async fn list_snapshots(creds: &Credentials) -> Result<Vec<Snapshot>> {
let response: SnapshotsListResponse = make_request("GET", "/snapshots", creds, None::<&()>).await?;
Ok(response.snapshots)
}
/// Restore a snapshot to create a new session or service.
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to restore
/// * `creds` - API credentials
///
/// # Returns
/// RestoreResult with new session/service ID
pub async fn restore_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<RestoreResult> {
let path = format!("/snapshots/{}/restore", snapshot_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Delete a snapshot.
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to delete
/// * `creds` - API credentials
pub async fn delete_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<()> {
let path = format!("/snapshots/{}", snapshot_id);
let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>).await?;
Ok(())
}
/// Lock a snapshot to prevent deletion.
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to lock
/// * `creds` - API credentials
///
/// # Returns
/// Updated Snapshot information
pub async fn lock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<Snapshot> {
let path = format!("/snapshots/{}/lock", snapshot_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Unlock a snapshot to allow deletion.
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to unlock
/// * `creds` - API credentials
///
/// # Returns
/// Updated Snapshot information
pub async fn unlock_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<Snapshot> {
let path = format!("/snapshots/{}/unlock", snapshot_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Clone a snapshot to create a new snapshot with a different name.
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to clone
/// * `name` - Name for the new snapshot
/// * `creds` - API credentials
///
/// # Returns
/// New Snapshot information
pub async fn clone_snapshot(snapshot_id: &str, name: &str, creds: &Credentials) -> Result<Snapshot> {
let path = format!("/snapshots/{}/clone", snapshot_id);
let body = serde_json::json!({
"name": name
});
make_request("POST", &path, creds, Some(&body)).await
}
// =============================================================================
// Session API Functions
// =============================================================================
/// List all sessions for the authenticated account.
///
/// # Arguments
/// * `creds` - API credentials
///
/// # Returns
/// Vector of Session information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let sessions = list_sessions(&creds).await?;
/// for session in sessions {
/// println!("{}: {} ({})", session.session_id, session.container_name, session.status);
/// }
/// ```
pub async fn list_sessions(creds: &Credentials) -> Result<Vec<Session>> {
let response: SessionsListResponse = make_request("GET", "/sessions", creds, None::<&()>).await?;
Ok(response.sessions)
}
/// Get details of a specific session.
///
/// # Arguments
/// * `session_id` - Session ID to retrieve
/// * `creds` - API credentials
///
/// # Returns
/// Session information
pub async fn get_session(session_id: &str, creds: &Credentials) -> Result<Session> {
let path = format!("/sessions/{}", session_id);
make_request("GET", &path, creds, None::<&()>).await
}
/// Create a new interactive session.
///
/// # Arguments
/// * `language` - Programming language/shell (e.g., "bash", "python")
/// * `creds` - API credentials
/// * `opts` - Optional session creation options
///
/// # Returns
/// Created Session information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // Create a basic bash session
/// let session = create_session("bash", &creds, None).await?;
///
/// // Create a session with options
/// let opts = SessionCreateOptions {
/// network_mode: Some("semitrusted".to_string()),
/// tmux: Some(true),
/// ..Default::default()
/// };
/// let session = create_session("bash", &creds, Some(opts)).await?;
/// ```
pub async fn create_session(
language: &str,
creds: &Credentials,
opts: Option<SessionCreateOptions>,
) -> Result<Session> {
let mut body = serde_json::json!({
"language": language
});
if let Some(opts) = opts {
if let Some(network_mode) = opts.network_mode {
body["network_mode"] = serde_json::json!(network_mode);
}
if let Some(shell) = opts.shell {
body["shell"] = serde_json::json!(shell);
}
if let Some(vcpu) = opts.vcpu {
body["vcpu"] = serde_json::json!(vcpu);
}
if let Some(tmux) = opts.tmux {
body["tmux"] = serde_json::json!(tmux);
}
if let Some(screen) = opts.screen {
body["screen"] = serde_json::json!(screen);
}
}
make_request("POST", "/sessions", creds, Some(&body)).await
}
/// Delete (terminate) a session.
///
/// # Arguments
/// * `session_id` - Session ID to delete
/// * `creds` - API credentials
pub async fn delete_session(session_id: &str, creds: &Credentials) -> Result<()> {
let path = format!("/sessions/{}", session_id);
let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>).await?;
Ok(())
}
/// Freeze a session to save resources while preserving state.
///
/// Frozen sessions can be unfrozen later to resume work.
///
/// # Arguments
/// * `session_id` - Session ID to freeze
/// * `creds` - API credentials
///
/// # Returns
/// Updated Session information
pub async fn freeze_session(session_id: &str, creds: &Credentials) -> Result<Session> {
let path = format!("/sessions/{}/freeze", session_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Unfreeze a frozen session to resume work.
///
/// # Arguments
/// * `session_id` - Session ID to unfreeze
/// * `creds` - API credentials
///
/// # Returns
/// Updated Session information
pub async fn unfreeze_session(session_id: &str, creds: &Credentials) -> Result<Session> {
let path = format!("/sessions/{}/unfreeze", session_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Boost a session's resources (increase vCPU and memory).
///
/// Memory is derived from vCPU: vcpu * 2048MB.
///
/// # Arguments
/// * `session_id` - Session ID to boost
/// * `creds` - API credentials
///
/// # Returns
/// Updated Session information
pub async fn boost_session(session_id: &str, creds: &Credentials) -> Result<Session> {
let path = format!("/sessions/{}/boost", session_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Remove boost from a session (return to base resources).
///
/// # Arguments
/// * `session_id` - Session ID to unboost
/// * `creds` - API credentials
///
/// # Returns
/// Updated Session information
pub async fn unboost_session(session_id: &str, creds: &Credentials) -> Result<Session> {
let path = format!("/sessions/{}/unboost", session_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Execute a shell command in a session.
///
/// # Arguments
/// * `session_id` - Session ID to execute command in
/// * `command` - Command to execute
/// * `creds` - API credentials
///
/// # Returns
/// ShellResult with output and exit code
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let result = shell_session("session-123", "ls -la", &creds).await?;
/// println!("Output: {}", result.output);
/// println!("Exit code: {}", result.exit_code);
/// ```
pub async fn shell_session(session_id: &str, command: &str, creds: &Credentials) -> Result<ShellResult> {
let path = format!("/sessions/{}/shell", session_id);
let body = serde_json::json!({
"command": command
});
make_request("POST", &path, creds, Some(&body)).await
}
// =============================================================================
// Service API Functions
// =============================================================================
/// List all services for the authenticated account.
///
/// # Arguments
/// * `creds` - API credentials
///
/// # Returns
/// Vector of Service information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let services = list_services(&creds).await?;
/// for service in services {
/// println!("{}: {} ({}) - {}", service.service_id, service.name, service.status, service.url);
/// }
/// ```
pub async fn list_services(creds: &Credentials) -> Result<Vec<Service>> {
let response: ServicesListResponse = make_request("GET", "/services", creds, None::<&()>).await?;
Ok(response.services)
}
/// Create a new persistent service.
///
/// # Arguments
/// * `name` - Service name (used in URL: name.on.unsandbox.com)
/// * `ports` - Ports to expose
/// * `bootstrap` - Bootstrap script content to run on startup
/// * `creds` - API credentials
/// * `opts` - Optional service creation options
///
/// # Returns
/// Created Service information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // Create a simple web service
/// let service = create_service(
/// "myapp",
/// &[8080],
/// "python3 -m http.server 8080",
/// &creds,
/// None
/// ).await?;
/// println!("Service URL: {}", service.url);
///
/// // Create with options
/// let opts = ServiceCreateOptions {
/// network_mode: Some("semitrusted".to_string()),
/// vcpu: Some(2),
/// domains: Some(vec!["example.com".to_string()]),
/// ..Default::default()
/// };
/// let service = create_service("myapp", &[80, 443], bootstrap, &creds, Some(opts)).await?;
/// ```
pub async fn create_service(
name: &str,
ports: &[u16],
bootstrap: &str,
creds: &Credentials,
opts: Option<ServiceCreateOptions>,
) -> Result<Service> {
let mut body = serde_json::json!({
"name": name,
"ports": ports,
"bootstrap": bootstrap
});
if let Some(opts) = opts {
if let Some(network_mode) = opts.network_mode {
body["network_mode"] = serde_json::json!(network_mode);
}
if let Some(vcpu) = opts.vcpu {
body["vcpu"] = serde_json::json!(vcpu);
}
if let Some(domains) = opts.domains {
body["domains"] = serde_json::json!(domains);
}
if let Some(bootstrap_url) = opts.bootstrap_url {
body["bootstrap_url"] = serde_json::json!(bootstrap_url);
}
}
make_request("POST", "/services", creds, Some(&body)).await
}
/// Get details of a specific service.
///
/// # Arguments
/// * `service_id` - Service ID to retrieve
/// * `creds` - API credentials
///
/// # Returns
/// Service information
pub async fn get_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}", service_id);
make_request("GET", &path, creds, None::<&()>).await
}
/// Update a service's configuration.
///
/// # Arguments
/// * `service_id` - Service ID to update
/// * `creds` - API credentials
/// * `opts` - Update options (name, ports, domains, vcpu)
///
/// # Returns
/// Updated Service information
pub async fn update_service(
service_id: &str,
creds: &Credentials,
opts: ServiceUpdateOptions,
) -> Result<Service> {
let path = format!("/services/{}", service_id);
let mut body = serde_json::Map::new();
if let Some(name) = opts.name {
body.insert("name".to_string(), serde_json::json!(name));
}
if let Some(ports) = opts.ports {
body.insert("ports".to_string(), serde_json::json!(ports));
}
if let Some(domains) = opts.domains {
body.insert("domains".to_string(), serde_json::json!(domains));
}
if let Some(vcpu) = opts.vcpu {
body.insert("vcpu".to_string(), serde_json::json!(vcpu));
}
make_request("PATCH", &path, creds, Some(&serde_json::Value::Object(body))).await
}
/// Delete (destroy) a service.
///
/// # Arguments
/// * `service_id` - Service ID to delete
/// * `creds` - API credentials
pub async fn delete_service(service_id: &str, creds: &Credentials) -> Result<()> {
let path = format!("/services/{}", service_id);
let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>).await?;
Ok(())
}
/// Freeze a service to save resources while preserving state.
///
/// # Arguments
/// * `service_id` - Service ID to freeze
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub async fn freeze_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/freeze", service_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Unfreeze a frozen service to resume operation.
///
/// # Arguments
/// * `service_id` - Service ID to unfreeze
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub async fn unfreeze_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/unfreeze", service_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Lock a service to prevent modifications.
///
/// # Arguments
/// * `service_id` - Service ID to lock
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub async fn lock_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/lock", service_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Unlock a service to allow modifications.
///
/// # Arguments
/// * `service_id` - Service ID to unlock
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub async fn unlock_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/unlock", service_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Get bootstrap logs for a service.
///
/// # Arguments
/// * `service_id` - Service ID to get logs for
/// * `all` - If true, get all logs; if false, get last 9000 lines
/// * `creds` - API credentials
///
/// # Returns
/// Log content as string
pub async fn get_service_logs(service_id: &str, all: bool, creds: &Credentials) -> Result<String> {
let path = if all {
format!("/services/{}/logs?all=true", service_id)
} else {
format!("/services/{}/logs", service_id)
};
#[derive(Deserialize)]
struct LogsResponse {
#[serde(default)]
logs: String,
}
let response: LogsResponse = make_request("GET", &path, creds, None::<&()>).await?;
Ok(response.logs)
}
/// Get environment variables for a service.
///
/// # Arguments
/// * `service_id` - Service ID to get env for
/// * `creds` - API credentials
///
/// # Returns
/// HashMap of environment variable key-value pairs
pub async fn get_service_env(service_id: &str, creds: &Credentials) -> Result<HashMap<String, String>> {
let path = format!("/services/{}/env", service_id);
let response: EnvResponse = make_request("GET", &path, creds, None::<&()>).await?;
Ok(response.env)
}
/// Set environment variables for a service.
///
/// # Arguments
/// * `service_id` - Service ID to set env for
/// * `env` - HashMap of environment variable key-value pairs
/// * `creds` - API credentials
pub async fn set_service_env(
service_id: &str,
env: &HashMap<String, String>,
creds: &Credentials,
) -> Result<()> {
let path = format!("/services/{}/env", service_id);
// Convert to .env format
let content: String = env
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("\n");
// Use PUT with text/plain content type
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()?;
let url = format!("{}{}", API_BASE, path);
let timestamp = get_timestamp();
let signature = sign_request(&creds.secret_key, timestamp, "PUT", &path, &content);
let response = client
.put(&url)
.header("Authorization", format!("Bearer {}", creds.public_key))
.header("X-Timestamp", timestamp.to_string())
.header("X-Signature", signature)
.header("Content-Type", "text/plain")
.header("User-Agent", "un-rust-async/2.0")
.body(content)
.send()
.await?;
let status = response.status().as_u16();
if status < 200 || status >= 300 {
let response_text = response.text().await?;
return Err(UnsandboxError::ApiError {
status,
message: response_text,
});
}
Ok(())
}
/// Delete environment variables for a service.
///
/// # Arguments
/// * `service_id` - Service ID to delete env for
/// * `keys` - List of environment variable keys to delete
/// * `creds` - API credentials
pub async fn delete_service_env(
service_id: &str,
keys: &[&str],
creds: &Credentials,
) -> Result<()> {
let path = format!("/services/{}/env", service_id);
let body = serde_json::json!({
"keys": keys
});
let _: serde_json::Value = make_request("DELETE", &path, creds, Some(&body)).await?;
Ok(())
}
/// Export environment variables for a service in .env format.
///
/// # Arguments
/// * `service_id` - Service ID to export env for
/// * `creds` - API credentials
///
/// # Returns
/// Environment variables in .env format string
pub async fn export_service_env(service_id: &str, creds: &Credentials) -> Result<String> {
let path = format!("/services/{}/env/export", service_id);
let body = serde_json::json!({});
let response: EnvExportResponse = make_request("POST", &path, creds, Some(&body)).await?;
Ok(response.content)
}
/// Redeploy a service with a new bootstrap script.
///
/// # Arguments
/// * `service_id` - Service ID to redeploy
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
pub async fn redeploy_service(service_id: &str, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/redeploy", service_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body)).await
}
/// Execute a command in a service container.
///
/// # Arguments
/// * `service_id` - Service ID to execute command in
/// * `command` - Command to execute
/// * `creds` - API credentials
///
/// # Returns
/// ExecuteResult with output and exit code
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let result = execute_in_service("service-123", "ls -la /app", &creds).await?;
/// println!("Output: {}", result.output);
/// ```
pub async fn execute_in_service(
service_id: &str,
command: &str,
creds: &Credentials,
) -> Result<ExecuteResult> {
let path = format!("/services/{}/execute", service_id);
let body = serde_json::json!({
"command": command,
"timeout": 30000
});
make_request("POST", &path, creds, Some(&body)).await
}
// =============================================================================
// Key Validation API Functions
// =============================================================================
/// Validate API keys.
///
/// # Arguments
/// * `creds` - API credentials to validate
///
/// # Returns
/// KeysValid with validation result and account info
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let result = validate_keys(&creds).await?;
/// if result.valid {
/// println!("Keys valid for account: {}", result.account_id);
/// } else {
/// println!("Invalid keys: {}", result.error);
/// }
/// ```
pub async fn validate_keys(creds: &Credentials) -> Result<KeysValid> {
// Note: This endpoint is on the portal, not the API
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
let url = "https://unsandbox.com/keys/validate";
let path = "/keys/validate";
let timestamp = get_timestamp();
let body_str = "";
let signature = sign_request(&creds.secret_key, timestamp, "POST", path, body_str);
let response = client
.post(url)
.header("Authorization", format!("Bearer {}", creds.public_key))
.header("X-Timestamp", timestamp.to_string())
.header("X-Signature", signature)
.header("Content-Type", "application/json")
.header("User-Agent", "un-rust-async/2.0")
.body("")
.send()
.await?;
let status = response.status().as_u16();
let response_text = response.text().await?;
if status < 200 || status >= 300 {
return Err(UnsandboxError::ApiError {
status,
message: response_text,
});
}
let result: KeysValid = serde_json::from_str(&response_text)?;
Ok(result)
}
// =============================================================================
// AI Image Generation API Functions
// =============================================================================
/// Generate images from a text prompt using AI.
///
/// # Arguments
/// * `prompt` - Text description of the image to generate
/// * `creds` - API credentials
/// * `opts` - Optional generation parameters
///
/// # Returns
/// * `Result<ImageResult>` - Generated images
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let result = image("A sunset over mountains", &creds, None).await?;
/// for img in result.images {
/// println!("Image: {}", img);
/// }
///
/// // With options
/// let opts = ImageOptions {
/// size: Some("512x512".to_string()),
/// quality: Some("hd".to_string()),
/// n: Some(2),
/// ..Default::default()
/// };
/// let result = image("A futuristic city", &creds, Some(opts)).await?;
/// ```
pub async fn image(prompt: &str, creds: &Credentials, opts: Option<ImageOptions>) -> Result<ImageResult> {
let opts = opts.unwrap_or_default();
let payload = serde_json::json!({
"prompt": prompt,
"size": opts.size.unwrap_or_else(|| "1024x1024".to_string()),
"quality": opts.quality.unwrap_or_else(|| "standard".to_string()),
"n": opts.n.unwrap_or(1),
"model": opts.model,
});
make_request("POST", "/image", creds, Some(&payload)).await
}
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_language() {
assert_eq!(detect_language("hello.py"), Some("python"));
assert_eq!(detect_language("script.js"), Some("javascript"));
assert_eq!(detect_language("main.go"), Some("go"));
assert_eq!(detect_language("test.rs"), Some("rust"));
assert_eq!(detect_language("app.ts"), Some("typescript"));
assert_eq!(detect_language("Makefile"), None);
assert_eq!(detect_language("unknown"), None);
assert_eq!(detect_language("file.unknown_ext"), None);
}
#[test]
fn test_sign_request() {
let signature = sign_request(
"test-secret",
1234567890,
"POST",
"/execute",
r#"{"language":"python","code":"print(42)"}"#,
);
// Signature should be 64 hex characters
assert_eq!(signature.len(), 64);
assert!(signature.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_credentials_new() {
let creds = Credentials::new("unsb-pk-test", "unsb-sk-test");
assert_eq!(creds.public_key, "unsb-pk-test");
assert_eq!(creds.secret_key, "unsb-sk-test");
}
#[test]
fn test_get_timestamp() {
let ts = get_timestamp();
// Should be a reasonable Unix timestamp (after 2024)
assert!(ts > 1700000000);
}
#[tokio::test]
async fn test_async_functions_compile() {
// This test just verifies the async functions compile correctly
// Actual API tests would require credentials
let creds = Credentials::new("unsb-pk-test", "unsb-sk-test");
assert_eq!(creds.public_key, "unsb-pk-test");
}
}