1mod channel;
26mod client;
27mod code;
28mod commands;
29mod error;
30mod files;
31mod git;
32mod http;
33mod types;
34
35pub use channel::{AsyncFrame, ControlChannel};
36pub use client::{Client, ClientOptions, CreateOptions, Sandbox};
37pub use code::{Code, RunCodeOptions};
38pub use commands::{CommandHandle, Commands, OutputCallback, RunOptions};
39pub use error::SolariError;
40pub use files::Files;
41pub use git::{
42 Git, GitCloneOptions, GitCommitOptions, GitLogOptions, GitRemoteOptions, GitRunner,
43};
44pub use http::{new_idempotency_key, HttpOptions, HttpTransport};
45pub use types::{
46 Chart, ChartAxis, ChartType, CodeResultItem, CommandResult, CreateSandboxRequest,
47 CreateSandboxResponse, FsEntry, FsStat, GatewayErrorBody, GitBranch, GitCommit, GitStatus,
48 Lifecycle, ResumeResponse, RunCodeResult, SandboxView, StartResponse, VolumeAttachment,
49};
50
51#[cfg(test)]
52mod tests {
53 use super::*;
54 use base64::Engine;
55 use futures_util::{SinkExt, StreamExt};
56 use std::sync::{Arc, Mutex};
57 use tokio::io::{AsyncReadExt, AsyncWriteExt};
58 use tokio::net::TcpListener;
59
60 #[tokio::test]
63 async fn commands_run_over_mock_ws() {
64 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
66 let ws_port = listener.local_addr().unwrap().port();
67 tokio::spawn(async move {
68 let (stream, _) = listener.accept().await.unwrap();
69 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
70 let msg = ws.next().await.unwrap().unwrap();
72 let text = msg.into_text().unwrap();
73 let req: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
74 assert_eq!(req.get("method").unwrap(), "cmd.start");
75 let id = req.get("id").unwrap().as_str().unwrap().to_string();
76 let hello = base64::engine::general_purpose::STANDARD.encode("hello\n");
77 let world = base64::engine::general_purpose::STANDARD.encode("oops");
78 let frames = vec![
80 format!("{{\"id\":\"{id}\",\"ok\":true,\"result\":{{\"cmdId\":\"c1\"}}}}"),
81 format!("{{\"type\":\"cmd.data\",\"cmdId\":\"c1\",\"stream\":\"stdout\",\"base64\":\"{hello}\"}}"),
82 format!("{{\"type\":\"cmd.data\",\"cmdId\":\"c1\",\"stream\":\"stderr\",\"base64\":\"{world}\"}}"),
83 "{\"type\":\"cmd.exit\",\"cmdId\":\"c1\",\"exitCode\":3}".to_string(),
84 ];
85 for f in frames {
86 ws.send(tokio_tungstenite::tungstenite::Message::Text(
87 format!("{f}\n").into(),
88 ))
89 .await
90 .unwrap();
91 }
92 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
94 });
95
96 let resp = CreateSandboxResponse {
99 sandbox_id: "sb".into(),
100 kind: "sandbox".into(),
101 control_url: format!("ws://127.0.0.1:{ws_port}/control/sb"),
102 expires_at: "".into(),
103 stream_url: None,
104 };
105 let sbx = Sandbox::from_response(
106 Arc::new(HttpTransport::new(HttpOptions::new("k", "http://127.0.0.1:1")).unwrap()),
107 resp,
108 Some(5000),
109 )
110 .unwrap();
111 sbx.connect().await.unwrap();
112
113 let out = sbx.commands().run("echo", RunOptions::new().args(["hello"])).await.unwrap();
114 assert_eq!(out.exit_code, 3);
115 assert_eq!(out.stdout, "hello\n");
116 assert_eq!(out.stderr, "oops");
117 }
118
119 #[tokio::test]
123 async fn files_list_and_stat_parse_wire_shape() {
124 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
125 let ws_port = listener.local_addr().unwrap().port();
126 tokio::spawn(async move {
127 let (stream, _) = listener.accept().await.unwrap();
128 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
129 for _ in 0..2 {
131 let msg = ws.next().await.unwrap().unwrap();
132 let text = msg.into_text().unwrap();
133 let req: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
134 let id = req.get("id").unwrap().as_str().unwrap().to_string();
135 let method = req.get("method").unwrap().as_str().unwrap();
136 let result = match method {
137 "fs.list" => "{\"entries\":[{\"name\":\"a.txt\",\"dir\":false,\"size\":12},{\"name\":\"sub\",\"dir\":true,\"size\":4096}]}".to_string(),
138 "fs.stat" => "{\"name\":\"a.txt\",\"dir\":false,\"size\":12,\"mode\":420,\"modTimeMs\":1720000000000}".to_string(),
139 _ => "{}".to_string(),
140 };
141 ws.send(tokio_tungstenite::tungstenite::Message::Text(
142 format!("{{\"id\":\"{id}\",\"ok\":true,\"result\":{result}}}\n").into(),
143 ))
144 .await
145 .unwrap();
146 }
147 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
148 });
149
150 let resp = CreateSandboxResponse {
151 sandbox_id: "sb".into(),
152 kind: "sandbox".into(),
153 control_url: format!("ws://127.0.0.1:{ws_port}/control/sb"),
154 expires_at: "".into(),
155 stream_url: None,
156 };
157 let sbx = Sandbox::from_response(
158 Arc::new(HttpTransport::new(HttpOptions::new("k", "http://127.0.0.1:1")).unwrap()),
159 resp,
160 Some(5000),
161 )
162 .unwrap();
163 sbx.connect().await.unwrap();
164
165 let entries = sbx.files().list("/work").await.unwrap();
166 assert_eq!(entries.len(), 2);
167 assert_eq!(entries[0].name, "a.txt");
168 assert!(!entries[0].dir);
169 assert_eq!(entries[0].size, 12);
170 assert_eq!(entries[1].name, "sub");
171 assert!(entries[1].dir);
172 assert_eq!(entries[1].size, 4096);
173
174 let st = sbx.files().stat("/work/a.txt").await.unwrap();
175 assert_eq!(st.name, "a.txt");
176 assert!(!st.dir);
177 assert_eq!(st.size, 12);
178 assert_eq!(st.mode, 420);
179 assert_eq!(st.mod_time_ms, 1720000000000);
180 }
181
182 #[tokio::test]
185 async fn create_rest_request_shape() {
186 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
187 let port = listener.local_addr().unwrap().port();
188 let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
189 let cap = captured.clone();
190 tokio::spawn(async move {
191 let (mut sock, _) = listener.accept().await.unwrap();
192 let mut buf = Vec::new();
194 let mut tmp = [0u8; 1024];
195 loop {
196 let n = sock.read(&mut tmp).await.unwrap();
197 if n == 0 {
198 break;
199 }
200 buf.extend_from_slice(&tmp[..n]);
201 if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
202 let head = String::from_utf8_lossy(&buf[..pos]).to_string();
203 let clen = content_length(&head).unwrap_or(0);
204 let body_start = pos + 4;
205 while buf.len() < body_start + clen {
206 let n = sock.read(&mut tmp).await.unwrap();
207 if n == 0 {
208 break;
209 }
210 buf.extend_from_slice(&tmp[..n]);
211 }
212 *cap.lock().unwrap() = Some(String::from_utf8_lossy(&buf).to_string());
213 break;
214 }
215 }
216 let body = r#"{"sandboxId":"sbx_1","kind":"sandbox","controlUrl":"ws://x/control/sbx_1","expiresAt":"2026-01-01T00:00:00Z"}"#;
217 let resp = format!(
218 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
219 body.len(),
220 body
221 );
222 sock.write_all(resp.as_bytes()).await.unwrap();
223 sock.flush().await.unwrap();
224 });
225
226 let client =
227 Client::new(ClientOptions::new("secret-key", format!("http://127.0.0.1:{port}"))).unwrap();
228 let sbx = client
229 .create(CreateOptions {
230 template: Some("base".into()),
231 ..Default::default()
232 })
233 .await
234 .unwrap();
235 assert_eq!(sbx.id(), "sbx_1");
236 assert_eq!(sbx.control_url(), "ws://x/control/sbx_1");
237
238 let raw = captured.lock().unwrap().clone().unwrap();
239 let (head, body) = raw.split_once("\r\n\r\n").unwrap();
240 assert!(head.starts_with("POST /sandboxes "), "head: {head}");
242 let lower = head.to_lowercase();
243 assert!(lower.contains("authorization: bearer secret-key"), "head: {head}");
244 assert!(lower.contains("accept: application/json"));
245 assert!(lower.contains("content-type: application/json"));
246 assert!(lower.contains("idempotency-key: "), "missing idempotency key: {head}");
247 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
249 assert_eq!(json.get("template").unwrap(), "base");
250 assert_eq!(json.get("kind").unwrap(), "sandbox");
251 assert!(json.get("cpu").is_none());
253 assert!(json.get("fromSnapshot").is_none());
254 assert_eq!(sbx.kind(), "sandbox");
256 assert_eq!(sbx.stream_url(), None);
257 }
258
259 #[tokio::test]
262 async fn create_desktop_sends_kind_and_surfaces_stream_url() {
263 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
264 let port = listener.local_addr().unwrap().port();
265 let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
266 let cap = captured.clone();
267 tokio::spawn(async move {
268 let (mut sock, _) = listener.accept().await.unwrap();
269 let mut buf = Vec::new();
270 let mut tmp = [0u8; 1024];
271 loop {
272 let n = sock.read(&mut tmp).await.unwrap();
273 if n == 0 {
274 break;
275 }
276 buf.extend_from_slice(&tmp[..n]);
277 if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
278 let head = String::from_utf8_lossy(&buf[..pos]).to_string();
279 let clen = content_length(&head).unwrap_or(0);
280 let body_start = pos + 4;
281 while buf.len() < body_start + clen {
282 let n = sock.read(&mut tmp).await.unwrap();
283 if n == 0 {
284 break;
285 }
286 buf.extend_from_slice(&tmp[..n]);
287 }
288 *cap.lock().unwrap() = Some(String::from_utf8_lossy(&buf).to_string());
289 break;
290 }
291 }
292 let body = r#"{"sandboxId":"sbx_d","kind":"desktop","controlUrl":"ws://x/control/sbx_d","expiresAt":"2026-01-01T00:00:00Z","streamUrl":"ws://x/stream/sbx_d"}"#;
294 let resp = format!(
295 "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
296 body.len(),
297 body
298 );
299 sock.write_all(resp.as_bytes()).await.unwrap();
300 sock.flush().await.unwrap();
301 });
302
303 let client =
304 Client::new(ClientOptions::new("secret-key", format!("http://127.0.0.1:{port}"))).unwrap();
305 let sbx = client
306 .create_desktop(CreateOptions {
307 template: Some("default".into()),
308 cpu: Some(2),
309 ..Default::default()
310 })
311 .await
312 .unwrap();
313
314 assert_eq!(sbx.id(), "sbx_d");
315 assert_eq!(sbx.kind(), "desktop");
316 assert_eq!(sbx.stream_url(), Some("ws://x/stream/sbx_d"));
317 assert_eq!(sbx.control_url(), "ws://x/control/sbx_d");
318
319 let raw = captured.lock().unwrap().clone().unwrap();
320 let (head, body) = raw.split_once("\r\n\r\n").unwrap();
321 assert!(head.starts_with("POST /sandboxes "), "head: {head}");
322 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
323 assert_eq!(json.get("kind").unwrap(), "desktop");
325 assert_eq!(json.get("template").unwrap(), "default");
326 assert_eq!(json.get("cpu").unwrap(), 2);
327 }
328
329 fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
330 haystack.windows(needle.len()).position(|w| w == needle)
331 }
332
333 fn content_length(head: &str) -> Option<usize> {
334 for line in head.lines() {
335 if let Some(v) = line.to_lowercase().strip_prefix("content-length:") {
336 return v.trim().parse().ok();
337 }
338 }
339 None
340 }
341
342 #[tokio::test]
346 async fn blank_id_guard() {
347 let resp = CreateSandboxResponse {
348 sandbox_id: "".into(),
349 kind: "sandbox".into(),
350 control_url: "ws://127.0.0.1:1/control/".into(),
351 expires_at: "".into(),
352 stream_url: None,
353 };
354 let http = Arc::new(HttpTransport::new(HttpOptions::new("k", "http://127.0.0.1:1")).unwrap());
355 match Sandbox::from_response(http, resp, Some(5000)) {
356 Err(SolariError::Other(_)) => {}
357 Err(e) => panic!("from_response(blank) = Err({e:?}), want Err(Other)"),
358 Ok(_) => panic!("from_response(blank) = Ok, want Err(Other)"),
359 }
360
361 let client = Client::new(ClientOptions::new("k", "http://127.0.0.1:1")).unwrap();
362 for id in ["", " ", "\t"] {
363 match client.connect(id).await {
364 Err(SolariError::Validation { .. }) => {}
365 Err(e) => panic!("connect({id:?}) = Err({e:?}), want Validation"),
366 Ok(_) => panic!("connect({id:?}) = Ok, want Validation"),
367 }
368 }
369 }
370}