1use chrono::{Duration as ChronoDuration, Utc};
2use reqwest::{Client, Method};
3use serde::de::DeserializeOwned;
4use serde::Serialize;
5use sha2::{Digest, Sha256};
6use std::time::Duration;
7use thiserror::Error;
8use uuid::Uuid;
9
10use crate::{
11 CheckpointRequest, CommandEnvelope, CommandOutcome, CreateSessionRequest, FormSnapshot,
12 OpenPageRequest, PageId, PageState, RecoveryDecision, RecoveryStatus, RuntimeInfo, SessionId,
13 SessionState, WorkflowCheckpoint, WorkflowId, CURRENT_INTERFACE_VERSION,
14};
15
16pub const DEFAULT_MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024;
19pub const MAX_SDK_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024;
20
21#[derive(Debug, Error)]
26pub enum ClientError {
27 #[error("transport error: {0}")]
29 Transport(String),
30 #[error("HTTP {status}: {message}")]
32 Http { status: u16, message: String },
33 #[error("protocol error: {0}")]
35 Protocol(String),
36}
37
38impl ClientError {
39 fn redact(self, bearer: &str) -> Self {
40 match self {
41 Self::Transport(message) => Self::Transport(message.replace(bearer, "")),
42 Self::Http { status, message } => Self::Http {
43 status,
44 message: message.replace(bearer, ""),
45 },
46 Self::Protocol(message) => Self::Protocol(message.replace(bearer, "")),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default)]
53pub struct RequestOptions {
54 pub timeout: Option<Duration>,
56 pub correlation_id: Option<String>,
58 pub idempotency_key: Option<String>,
60}
61
62#[derive(Debug, Clone)]
67pub struct BrowserRuntimeClient {
68 base_url: String,
69 bearer_token: String,
70 http: Client,
71 default_timeout: Duration,
72 max_artifact_bytes: u64,
73}
74
75impl BrowserRuntimeClient {
76 pub fn new(
81 base_url: impl Into<String>,
82 bearer_token: impl Into<String>,
83 ) -> Result<Self, ClientError> {
84 Self::with_options(base_url, bearer_token, DEFAULT_MAX_ARTIFACT_BYTES)
85 }
86
87 pub fn with_options(
90 base_url: impl Into<String>,
91 bearer_token: impl Into<String>,
92 max_artifact_bytes: u64,
93 ) -> Result<Self, ClientError> {
94 let bearer_token = bearer_token.into();
95 if bearer_token.is_empty() {
96 return Err(ClientError::Protocol(
97 "bearerToken must not be empty".into(),
98 ));
99 }
100 if max_artifact_bytes == 0 || max_artifact_bytes > MAX_SDK_ARTIFACT_BYTES {
101 return Err(ClientError::Protocol(
102 "maxArtifactBytes must be positive and within the SDK allocation cap".into(),
103 ));
104 }
105 let base_url = normalize_base_url(base_url.into());
106 if base_url.is_empty() {
107 return Err(ClientError::Protocol("baseUrl must not be empty".into()));
108 }
109 let http = Client::builder()
110 .timeout(Duration::from_secs(30))
111 .build()
112 .map_err(|error| ClientError::Transport(error.to_string()))?;
113 Ok(Self {
114 base_url,
115 bearer_token,
116 http,
117 default_timeout: Duration::from_secs(30),
118 max_artifact_bytes,
119 })
120 }
121
122 pub async fn runtime_info(
124 &self,
125 options: Option<RequestOptions>,
126 ) -> Result<RuntimeInfo, ClientError> {
127 self.json(Method::GET, "/v1/runtime", None::<()>, options)
128 .await
129 }
130
131 pub async fn create_session(
133 &self,
134 input: &CreateSessionRequest,
135 options: Option<RequestOptions>,
136 ) -> Result<SessionState, ClientError> {
137 self.json(Method::POST, "/v1/sessions", Some(input), options)
138 .await
139 }
140
141 pub async fn list_sessions(
143 &self,
144 options: Option<RequestOptions>,
145 ) -> Result<Vec<SessionState>, ClientError> {
146 self.json(Method::GET, "/v1/sessions", None::<()>, options)
147 .await
148 }
149
150 pub async fn delete_session(
152 &self,
153 session_id: &SessionId,
154 options: Option<RequestOptions>,
155 ) -> Result<(), ClientError> {
156 self.empty(
157 Method::DELETE,
158 &format!("/v1/sessions/{}", session_id.0),
159 options,
160 )
161 .await
162 }
163
164 pub async fn open_page(
166 &self,
167 input: &OpenPageRequest,
168 options: Option<RequestOptions>,
169 ) -> Result<PageState, ClientError> {
170 self.json(Method::POST, "/v1/pages", Some(input), options)
171 .await
172 }
173
174 pub async fn submit(
176 &self,
177 input: &CommandEnvelope,
178 options: Option<RequestOptions>,
179 ) -> Result<CommandOutcome, ClientError> {
180 self.json(Method::POST, "/v1/commands", Some(input), options)
181 .await
182 }
183
184 async fn empty(
185 &self,
186 method: Method,
187 path: &str,
188 options: Option<RequestOptions>,
189 ) -> Result<(), ClientError> {
190 let options = options.unwrap_or_default();
191 let timeout = options.timeout.unwrap_or(self.default_timeout);
192 let deadline =
193 Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
194 let correlation = options
195 .correlation_id
196 .unwrap_or_else(|| Uuid::new_v4().to_string());
197
198 let mut request = self
199 .http
200 .request(method, format!("{}{path}", self.base_url))
201 .timeout(timeout)
202 .header("authorization", format!("Bearer {}", self.bearer_token))
203 .header("x-interface-version", CURRENT_INTERFACE_VERSION)
204 .header("x-correlation-id", correlation)
205 .header("x-deadline", deadline.to_rfc3339());
206 if let Some(key) = options.idempotency_key {
207 request = request.header("idempotency-key", key);
208 }
209
210 let response = request.send().await.map_err(|error| {
211 ClientError::Transport(error.to_string()).redact(&self.bearer_token)
212 })?;
213 let status = response.status();
214 if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
215 return Ok(());
216 }
217 let text = response.text().await.unwrap_or_default();
218 Err(ClientError::Http {
219 status: status.as_u16(),
220 message: text,
221 }
222 .redact(&self.bearer_token))
223 }
224
225 async fn json<B, T>(
226 &self,
227 method: Method,
228 path: &str,
229 body: Option<B>,
230 options: Option<RequestOptions>,
231 ) -> Result<T, ClientError>
232 where
233 B: Serialize,
234 T: DeserializeOwned,
235 {
236 let options = options.unwrap_or_default();
237 let timeout = options.timeout.unwrap_or(self.default_timeout);
238 let deadline =
239 Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
240 let correlation = options
241 .correlation_id
242 .unwrap_or_else(|| Uuid::new_v4().to_string());
243
244 let mut request = self
245 .http
246 .request(method, format!("{}{path}", self.base_url))
247 .timeout(timeout)
248 .header("authorization", format!("Bearer {}", self.bearer_token))
249 .header("x-interface-version", CURRENT_INTERFACE_VERSION)
250 .header("x-correlation-id", correlation)
251 .header("x-deadline", deadline.to_rfc3339());
252 if let Some(key) = options.idempotency_key {
253 request = request.header("idempotency-key", key);
254 }
255 if let Some(body) = body {
256 request = request
257 .header("content-type", "application/json")
258 .json(&body);
259 }
260
261 let response = request.send().await.map_err(|error| {
262 ClientError::Transport(error.to_string()).redact(&self.bearer_token)
263 })?;
264 let status = response.status();
265 let text = response.text().await.map_err(|error| {
266 ClientError::Transport(error.to_string()).redact(&self.bearer_token)
267 })?;
268 if !status.is_success() {
269 return Err(ClientError::Http {
270 status: status.as_u16(),
271 message: text,
272 }
273 .redact(&self.bearer_token));
274 }
275 serde_json::from_str(&text).map_err(|error| {
276 ClientError::Protocol(format!("invalid JSON body: {error}")).redact(&self.bearer_token)
277 })
278 }
279
280 async fn bytes_with_headers(
285 &self,
286 path: &str,
287 options: Option<RequestOptions>,
288 ) -> Result<(Vec<u8>, (String, Option<u64>)), ClientError> {
289 let options = options.unwrap_or_default();
290 let timeout = options.timeout.unwrap_or(self.default_timeout);
291 let deadline =
292 Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
293 let correlation = options
294 .correlation_id
295 .unwrap_or_else(|| Uuid::new_v4().to_string());
296 let response = self
297 .http
298 .get(format!("{}{path}", self.base_url))
299 .timeout(timeout)
300 .header("authorization", format!("Bearer {}", self.bearer_token))
301 .header("x-interface-version", CURRENT_INTERFACE_VERSION)
302 .header("x-correlation-id", correlation)
303 .header("x-deadline", deadline.to_rfc3339())
304 .send()
305 .await
306 .map_err(|error| {
307 ClientError::Transport(error.to_string()).redact(&self.bearer_token)
308 })?;
309 let status = response.status();
310 if !status.is_success() {
311 let text = response.text().await.unwrap_or_default();
312 return Err(ClientError::Http {
313 status: status.as_u16(),
314 message: text,
315 }
316 .redact(&self.bearer_token));
317 }
318 let content_type = response
319 .headers()
320 .get(reqwest::header::CONTENT_TYPE)
321 .and_then(|value| value.to_str().ok())
322 .map(|value| {
323 value
324 .split(';')
325 .next()
326 .unwrap_or_default()
327 .trim()
328 .to_ascii_lowercase()
329 })
330 .unwrap_or_default();
331 let content_length = response
332 .headers()
333 .get(reqwest::header::CONTENT_LENGTH)
334 .and_then(|value| value.to_str().ok())
335 .and_then(|value| value.parse::<u64>().ok());
336 let body = response.bytes().await.map_err(|error| {
337 ClientError::Transport(error.to_string()).redact(&self.bearer_token)
338 })?;
339 Ok((body.to_vec(), (content_type, content_length)))
340 }
341
342 pub async fn form_snapshot(
347 &self,
348 session_id: &SessionId,
349 page_id: &PageId,
350 max_controls: Option<u32>,
351 options: Option<RequestOptions>,
352 ) -> Result<FormSnapshot, ClientError> {
353 if let Some(bound) = max_controls {
354 if !(1..=512).contains(&bound) {
355 return Err(ClientError::Protocol(
356 "maxControls must be between 1 and 512".into(),
357 ));
358 }
359 }
360 let query = max_controls
361 .map(|bound| format!("?maxControls={bound}"))
362 .unwrap_or_default();
363 self.json(
364 Method::GET,
365 &format!(
366 "/v1/sessions/{}/pages/{}/forms{query}",
367 session_id.0, page_id.0
368 ),
369 None::<()>,
370 options,
371 )
372 .await
373 }
374
375 pub async fn checkpoint(
377 &self,
378 input: &CheckpointRequest,
379 options: Option<RequestOptions>,
380 ) -> Result<WorkflowCheckpoint, ClientError> {
381 self.json(Method::POST, "/v1/checkpoints", Some(input), options)
382 .await
383 }
384
385 pub async fn recovery_status(
387 &self,
388 workflow_id: &WorkflowId,
389 options: Option<RequestOptions>,
390 ) -> Result<RecoveryStatus, ClientError> {
391 self.json(
392 Method::GET,
393 &format!("/v1/recovery/{}", workflow_id.0),
394 None::<()>,
395 options,
396 )
397 .await
398 }
399
400 pub async fn recover(
404 &self,
405 workflow_id: &WorkflowId,
406 options: Option<RequestOptions>,
407 ) -> Result<RecoveryDecision, ClientError> {
408 self.json(
409 Method::POST,
410 &format!("/v1/recovery/{}", workflow_id.0),
411 None::<()>,
412 options,
413 )
414 .await
415 }
416
417 pub async fn artifact(
424 &self,
425 reference: &ArtifactReference,
426 options: Option<RequestOptions>,
427 ) -> Result<Vec<u8>, ClientError> {
428 validate_artifact_reference(reference, self.max_artifact_bytes)?;
429 let (bytes, (content_type, content_length)) = self
430 .bytes_with_headers(&format!("/v1/artifacts/{}", reference.artifact_id), options)
431 .await?;
432 if content_type != reference.media_type_essence() {
433 return Err(ClientError::Protocol(
434 "artifact media type does not match its reference".into(),
435 ));
436 }
437 if content_length.is_none_or(|length| length != reference.bytes)
438 || bytes.len() as u64 != reference.bytes
439 {
440 return Err(ClientError::Protocol(
441 "artifact content length does not match its reference".into(),
442 ));
443 }
444 let digest = hex::encode(Sha256::digest(&bytes));
445 if digest != reference.sha256 {
446 return Err(ClientError::Protocol("artifact verification failed".into()));
447 }
448 Ok(bytes)
449 }
450}
451
452#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct ArtifactReference {
457 pub artifact_id: String,
458 pub sha256: String,
459 pub bytes: u64,
460 pub media_type: String,
461}
462
463impl ArtifactReference {
464 pub fn media_type_essence(&self) -> String {
469 media_type_essence(&self.media_type).unwrap_or_default()
470 }
471}
472
473fn validate_artifact_reference(
474 reference: &ArtifactReference,
475 max_artifact_bytes: u64,
476) -> Result<(), ClientError> {
477 let artifact_id_ok = !reference.artifact_id.is_empty()
478 && reference.artifact_id.len() <= 128
479 && reference
480 .artifact_id
481 .bytes()
482 .all(|byte| byte.is_ascii_hexdigit() || byte == b'-');
483 let digest_ok = reference.sha256.len() == 64
484 && reference
485 .sha256
486 .bytes()
487 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
488 let media_type_ok = media_type_essence(&reference.media_type).is_some();
489 if !artifact_id_ok || !digest_ok || !media_type_ok || reference.bytes > max_artifact_bytes {
490 return Err(ClientError::Protocol(
491 "artifact reference is outside the client hard bound".into(),
492 ));
493 }
494 Ok(())
495}
496
497fn media_type_essence(value: &str) -> Option<String> {
500 const TOKEN_EXTRA: &[u8] = b"!#$%&'*+.^_`|~-";
501 let essence = value.split(';').next()?.trim();
502 let (type_part, subtype_part) = essence.split_once('/')?;
503 let valid = |part: &str| {
504 !part.is_empty()
505 && part
506 .bytes()
507 .all(|byte| byte.is_ascii_alphanumeric() || TOKEN_EXTRA.contains(&byte))
508 };
509 if !valid(type_part) || !valid(subtype_part) {
510 return None;
511 }
512 Some(essence.to_ascii_lowercase())
513}
514
515fn normalize_base_url(value: String) -> String {
516 let trimmed = value.trim_end_matches('/').to_string();
517 if let Some(stripped) = trimmed.strip_suffix("/v1") {
518 stripped.trim_end_matches('/').to_string()
519 } else {
520 trimmed
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::{AttemptId, CheckpointId, CommandId};
528 use axum::http::HeaderMap;
529 use axum::http::StatusCode;
530 use axum::response::IntoResponse;
531 use axum::routing::get;
532 use axum::Router;
533 use serde_json::json;
534 use std::sync::Arc;
535 use tokio::net::TcpListener;
536
537 async fn runtime_handler(headers: HeaderMap) -> impl IntoResponse {
538 assert!(headers
539 .get("authorization")
540 .and_then(|v| v.to_str().ok())
541 .is_some_and(|v| v == "Bearer test-token"));
542 assert_eq!(
543 headers
544 .get("x-interface-version")
545 .and_then(|v| v.to_str().ok()),
546 Some(CURRENT_INTERFACE_VERSION)
547 );
548 assert!(headers.get("x-correlation-id").is_some());
549 assert!(headers.get("x-deadline").is_some());
550 (
551 StatusCode::OK,
552 [(axum::http::header::CONTENT_TYPE, "application/json")],
553 json!({
554 "version": env!("CARGO_PKG_VERSION"),
555 "capabilities": ["session:read"],
556 "active_sessions": 0,
557 "queued_jobs": 0,
558 "uptime_ms": 1,
559 })
560 .to_string(),
561 )
562 }
563
564 #[tokio::test]
565 async fn runtime_info_sends_required_headers() {
566 let app = Router::new().route("/v1/runtime", get(runtime_handler));
567 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
568 let addr = listener.local_addr().unwrap();
569 tokio::spawn(async move {
570 axum::serve(listener, app).await.unwrap();
571 });
572
573 let client = BrowserRuntimeClient::new(format!("http://{addr}/v1"), "test-token").unwrap();
574 let info = client.runtime_info(None).await.unwrap();
575 assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
576 assert_eq!(info.active_sessions, 0);
577 }
578
579 #[tokio::test]
580 async fn normalize_strips_v1_suffix() {
581 assert_eq!(
582 normalize_base_url("http://127.0.0.1:7777/v1/".into()),
583 "http://127.0.0.1:7777"
584 );
585 }
586
587 #[tokio::test]
588 async fn rejects_empty_bearer() {
589 let err = BrowserRuntimeClient::new("http://127.0.0.1:7777", "").unwrap_err();
590 assert!(matches!(err, ClientError::Protocol(_)));
591 }
592
593 #[tokio::test]
594 async fn rejects_out_of_cap_max_artifact_bytes() {
595 let err = BrowserRuntimeClient::with_options(
596 "http://127.0.0.1:7777",
597 "test-token",
598 MAX_SDK_ARTIFACT_BYTES + 1,
599 )
600 .unwrap_err();
601 assert!(matches!(err, ClientError::Protocol(_)));
602 let err = BrowserRuntimeClient::with_options("http://127.0.0.1:7777", "test-token", 0)
603 .unwrap_err();
604 assert!(matches!(err, ClientError::Protocol(_)));
605 }
606
607 async fn spawn(app: Router) -> String {
608 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
609 let addr = listener.local_addr().unwrap();
610 tokio::spawn(async move {
611 axum::serve(listener, app).await.unwrap();
612 });
613 format!("http://{addr}")
614 }
615
616 async fn capture_uri(
617 path: &str,
618 response: impl IntoResponse + Clone + Send + Sync + 'static,
619 ) -> (String, tokio::sync::mpsc::Receiver<String>) {
620 let (tx, rx) = tokio::sync::mpsc::channel(8);
621 let response = Arc::new(response);
622 let app = Router::new().route(
623 path,
624 axum::routing::any(move |uri: axum::http::Uri| {
625 let response = Arc::clone(&response);
626 let tx = tx.clone();
627 async move {
628 let _ = tx.send(uri.to_string()).await;
629 (*response).clone()
630 }
631 }),
632 );
633 let base = spawn(app).await;
634 (base, rx)
635 }
636
637 #[tokio::test]
638 async fn form_snapshot_builds_bounded_query() {
639 let (base, mut rx) = capture_uri(
640 "/v1/sessions/{session}/pages/{page}/forms",
641 axum::Json(json!({
642 "schemaVersion": 1,
643 "pageId": "00000000-0000-4000-8000-000000000009",
644 "forms": [],
645 "unownedControls": [],
646 "truncated": false,
647 })),
648 )
649 .await;
650 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
651 let session = SessionId::new();
652 let page = PageId::new();
653 let snapshot = client
654 .form_snapshot(&session, &page, Some(7), None)
655 .await
656 .unwrap();
657 assert!(!snapshot.truncated);
658 assert_eq!(
659 rx.recv().await.unwrap(),
660 format!(
661 "/v1/sessions/{}/pages/{}/forms?maxControls=7",
662 session.0, page.0
663 )
664 );
665 assert!(client
666 .form_snapshot(&session, &page, Some(0), None)
667 .await
668 .is_err());
669 assert!(client
670 .form_snapshot(&session, &page, Some(513), None)
671 .await
672 .is_err());
673 }
674
675 #[tokio::test]
676 async fn checkpoint_posts_the_request_body() {
677 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
678 let app = Router::new().route(
679 "/v1/checkpoints",
680 axum::routing::post(move |body: String| {
681 let tx = tx.clone();
682 async move {
683 let _ = tx.send(body).await;
684 (
685 StatusCode::OK,
686 axum::Json(json!({
687 "schemaVersion": 1,
688 "checkpointId": CheckpointId::new(),
689 "workflowId": WorkflowId::new(),
690 "attemptId": AttemptId::new(),
691 "sessionId": SessionId::new(),
692 "pageId": PageId::new(),
693 "restartUrl": "https://example.test",
694 "currentUrl": "https://example.test",
695 "recoveryClass": "replayable",
696 "invariants": [],
697 "replayableInputs": [],
698 "evidence": [],
699 "createdAt": "2026-09-02T00:00:00Z",
700 })),
701 )
702 }
703 }),
704 );
705 let base = spawn(app).await;
706 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
707 let request = CheckpointRequest {
708 checkpoint: serde_json::from_value(json!({
709 "schemaVersion": 1,
710 "checkpointId": CheckpointId::new(),
711 "workflowId": WorkflowId::new(),
712 "attemptId": AttemptId::new(),
713 "sessionId": SessionId::new(),
714 "pageId": PageId::new(),
715 "restartUrl": "https://example.test",
716 "currentUrl": "https://example.test",
717 "recoveryClass": "replayable",
718 "invariants": [],
719 "replayableInputs": [],
720 "evidence": [],
721 "createdAt": "2026-09-02T00:00:00Z",
722 }))
723 .unwrap(),
724 evidence_refs: vec![CommandId::new()],
725 };
726 let checkpoint = client.checkpoint(&request, None).await.unwrap();
727 assert_eq!(
728 checkpoint.schema_version,
729 WorkflowCheckpoint::SCHEMA_VERSION
730 );
731 let sent: serde_json::Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap();
732 assert_eq!(sent["evidenceRefs"].as_array().map(Vec::len), Some(1));
733 }
734
735 #[tokio::test]
736 async fn recovery_status_gets_the_workflow_path() {
737 let workflow = WorkflowId::new();
738 let (base, mut rx) = capture_uri(
739 "/v1/recovery/{workflow}",
740 axum::Json(json!({
741 "workflowId": workflow.0,
742 "checkpoint": null,
743 "receipts": [],
744 })),
745 )
746 .await;
747 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
748 assert!(client.recovery_status(&workflow, None).await.is_err());
751 assert_eq!(
752 rx.recv().await.unwrap(),
753 format!("/v1/recovery/{}", workflow.0)
754 );
755 }
756
757 #[tokio::test]
758 async fn recover_posts_the_workflow_path() {
759 let workflow = WorkflowId::new();
760 let (base, mut rx) = capture_uri(
761 "/v1/recovery/{workflow}",
762 axum::Json(json!({
763 "status": "resumed",
764 "checkpointId": CheckpointId::new(),
765 "attemptId": AttemptId::new(),
766 "evidence": [],
767 })),
768 )
769 .await;
770 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
771 let decision = client.recover(&workflow, None).await.unwrap();
772 match decision {
773 RecoveryDecision::Resumed { .. } => {}
774 other => panic!("unexpected decision: {other:?}"),
775 }
776 assert_eq!(
777 rx.recv().await.unwrap(),
778 format!("/v1/recovery/{}", workflow.0)
779 );
780 }
781
782 #[tokio::test]
783 async fn recover_surfaces_needs_reconciliation_409_body() {
784 let workflow = WorkflowId::new();
785 let (base, mut rx) = capture_uri(
786 "/v1/recovery/{workflow}",
787 (
788 StatusCode::CONFLICT,
789 axum::Json(json!({
790 "status": "needsReconciliation",
791 "checkpointId": CheckpointId::new(),
792 "attemptId": AttemptId::new(),
793 "reason": "diverged",
794 "evidence": [],
795 })),
796 ),
797 )
798 .await;
799 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
800 let err = client.recover(&workflow, None).await.unwrap_err();
801 assert!(matches!(err, ClientError::Http { status: 409, .. }));
802 assert_eq!(
803 rx.recv().await.unwrap(),
804 format!("/v1/recovery/{}", workflow.0)
805 );
806 }
807
808 const ARTIFACT_BODY: &[u8] = b"artifact";
809 const ARTIFACT_DIGEST: &str =
811 "c7c5c1d70c5dec4416ab6158afd0b223ef40c29b1dc1f97ed9428b94d4cadb1c";
812
813 fn artifact_reference(sha256: &str, bytes: u64, media_type: &str) -> ArtifactReference {
814 ArtifactReference {
815 artifact_id: CommandId::new().0.to_string(),
816 sha256: sha256.into(),
817 bytes,
818 media_type: media_type.into(),
819 }
820 }
821
822 async fn spawn_artifact_server(
823 media_type: &'static str,
824 body: &'static [u8],
825 extra_headers: bool,
826 ) -> String {
827 let mut headers = axum::http::HeaderMap::new();
828 headers.insert(
829 axum::http::header::CONTENT_TYPE,
830 media_type.parse().unwrap(),
831 );
832 if extra_headers {
833 headers.insert(
834 axum::http::header::CONTENT_LENGTH,
835 body.len().to_string().parse().unwrap(),
836 );
837 }
838 let app = Router::new().route(
839 "/v1/artifacts/{id}",
840 get(move || {
841 let headers = headers.clone();
842 async move {
843 if extra_headers {
844 axum::response::IntoResponse::into_response((StatusCode::OK, headers, body))
845 } else {
846 let chunks = vec![Ok::<Vec<u8>, std::io::Error>(body.to_vec())];
848 axum::response::IntoResponse::into_response((
849 StatusCode::OK,
850 headers,
851 axum::body::Body::from_stream(futures_util::stream::iter(chunks)),
852 ))
853 }
854 }
855 }),
856 );
857 spawn(app).await
858 }
859
860 #[tokio::test]
861 async fn artifact_verifies_digest_length_and_media_type() {
862 let base = spawn_artifact_server("application/octet-stream", ARTIFACT_BODY, true).await;
863 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
864 let reference = artifact_reference(
865 ARTIFACT_DIGEST,
866 ARTIFACT_BODY.len() as u64,
867 "application/octet-stream",
868 );
869 let body = client.artifact(&reference, None).await.unwrap();
870 assert_eq!(body, ARTIFACT_BODY);
871
872 let bad_digest = artifact_reference(
873 &"00".repeat(32),
874 ARTIFACT_BODY.len() as u64,
875 "application/octet-stream",
876 );
877 let err = client.artifact(&bad_digest, None).await.unwrap_err();
878 assert!(
879 matches!(err, ClientError::Protocol(message) if message.contains("verification failed"))
880 );
881
882 let bad_media =
883 artifact_reference(ARTIFACT_DIGEST, ARTIFACT_BODY.len() as u64, "text/plain");
884 let err = client.artifact(&bad_media, None).await.unwrap_err();
885 assert!(matches!(err, ClientError::Protocol(message) if message.contains("media type")));
886
887 let bad_length = artifact_reference(
888 ARTIFACT_DIGEST,
889 ARTIFACT_BODY.len() as u64 + 1,
890 "application/octet-stream",
891 );
892 let err = client.artifact(&bad_length, None).await.unwrap_err();
893 assert!(
894 matches!(err, ClientError::Protocol(message) if message.contains("content length"))
895 );
896
897 let mixed_case = ArtifactReference {
902 media_type: "Application/Octet-Stream".into(),
903 ..artifact_reference(
904 ARTIFACT_DIGEST,
905 ARTIFACT_BODY.len() as u64,
906 "application/octet-stream",
907 )
908 };
909 assert_eq!(mixed_case.media_type_essence(), "application/octet-stream");
910 let empty_media = artifact_reference(ARTIFACT_DIGEST, ARTIFACT_BODY.len() as u64, "");
911 assert!(matches!(
912 validate_artifact_reference(&empty_media, DEFAULT_MAX_ARTIFACT_BYTES),
913 Err(ClientError::Protocol(message)) if message.contains("hard bound")
914 ));
915 let garbage_media = artifact_reference(
916 ARTIFACT_DIGEST,
917 ARTIFACT_BODY.len() as u64,
918 "not a media type",
919 );
920 assert!(matches!(
921 validate_artifact_reference(&garbage_media, DEFAULT_MAX_ARTIFACT_BYTES),
922 Err(ClientError::Protocol(message)) if message.contains("hard bound")
923 ));
924 let with_params = ArtifactReference {
926 media_type: "application/octet-stream; charset=binary".into(),
927 ..artifact_reference(
928 ARTIFACT_DIGEST,
929 ARTIFACT_BODY.len() as u64,
930 "application/octet-stream",
931 )
932 };
933 assert_eq!(with_params.media_type_essence(), "application/octet-stream");
934 }
935
936 #[tokio::test]
937 async fn artifact_rejects_missing_content_length() {
938 let base = spawn_artifact_server("application/octet-stream", ARTIFACT_BODY, false).await;
939 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
940 let reference = artifact_reference(
941 ARTIFACT_DIGEST,
942 ARTIFACT_BODY.len() as u64,
943 "application/octet-stream",
944 );
945 let err = client.artifact(&reference, None).await.unwrap_err();
946 assert!(
947 matches!(err, ClientError::Protocol(message) if message.contains("content length"))
948 );
949 }
950
951 #[tokio::test]
952 async fn artifact_reference_rejects_out_of_cap_before_any_request() {
953 let client =
954 BrowserRuntimeClient::with_options("http://127.0.0.1:7777", "test-token", 1).unwrap();
955 let reference = artifact_reference(ARTIFACT_DIGEST, 2, "application/octet-stream");
956 let err = client.artifact(&reference, None).await.unwrap_err();
957 assert!(matches!(err, ClientError::Protocol(_)));
958 }
959
960 #[tokio::test]
961 async fn artifact_reference_rejects_bad_shapes_before_any_request() {
962 for reference in [
963 artifact_reference(ARTIFACT_DIGEST, 8, ""),
964 artifact_reference("ZZ", 8, "application/octet-stream"),
965 artifact_reference("AB".repeat(32).as_str(), 8, "application/octet-stream"),
966 ] {
967 assert!(matches!(
968 validate_artifact_reference(&reference, DEFAULT_MAX_ARTIFACT_BYTES),
969 Err(ClientError::Protocol(_))
970 ));
971 }
972 let upper = artifact_reference(&"AB".repeat(32), 8, "application/octet-stream");
973 assert!(matches!(
974 validate_artifact_reference(&upper, DEFAULT_MAX_ARTIFACT_BYTES),
975 Err(ClientError::Protocol(_))
976 ));
977 }
978}