//! Optional HTTP transport. It never adopts a document or persists credentials. use oa_core::{ Id, Project, source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle}, }; use reqwest::{Client, Method, Url, header}; use serde::{Deserialize, de::DeserializeOwned}; use std::{fmt, net::IpAddr, sync::Arc, time::Duration}; // The service permits two 8 MiB representations plus envelope framing. Keep // transport/parser budgets finite even when connecting to a misconfigured host. const MAX_RESPONSE_BYTES: usize = 17 * 1024 * 1024; const MAX_PROBLEM_BYTES: usize = 256 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(40); const SOURCE_LIMITS: ProjectLimits = ProjectLimits { max_files: 256, max_include_depth: 32, max_include_edges: 1_024, model: oa_core::language::ParseLimits { max_source_bytes: 8 * 1024 * 1024, max_graph_objects: 5_000, max_records: 20_000, max_step_references: 50_000, }, }; #[derive(Clone, Default)] pub struct Secret(pub String); impl fmt::Debug for Secret { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("Secret([redacted])") } } #[derive(Clone)] pub struct Connection { base: Url, client: Client, token: Secret, } impl fmt::Debug for Connection { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Do not expose headers, credentials, or user-supplied URL text. f.debug_struct("Connection").finish_non_exhaustive() } } #[derive(Clone, Debug)] pub struct Remote { pub resource_id: Id, pub revision: u64, pub etag: String, pub compiled: Arc, } #[derive(Clone, Debug, Deserialize)] pub struct Catalog { pub items: Vec, pub next_after: Option, } #[derive(Clone, Debug, Deserialize)] pub struct Summary { pub resource_id: Id, pub revision: u64, pub model_id: Id, pub name: String, pub elements: usize, pub relations: usize, } #[derive(Clone, Debug)] pub struct Failure { pub detail: String, pub code: String, pub status: Option, /// A mutation may have committed. Read current state before another write. pub uncertain: bool, /// Publication was acknowledged, but its accepted source could not be read. pub published: Option, } impl Failure { fn local(code: &str, detail: &str) -> Self { Self { detail: detail.into(), code: code.into(), status: None, uncertain: false, published: None, } } } impl fmt::Display for Failure { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.detail) } } impl std::error::Error for Failure {} #[derive(Deserialize)] struct Envelope { resource_id: Id, revision: u64, model: Project, source_project: SourceBundle, } #[derive(Deserialize)] struct AcceptedSource { revision: u64, source_project: SourceBundle, } #[derive(Deserialize)] struct Problem { code: String, detail: String, #[serde(default)] issues: Vec, } #[derive(Deserialize)] struct ProblemIssue { message: String, path: Option, line: Option, column: Option, } struct Reply { status: u16, etag: Option, body: Vec, } impl Connection { /// `base` is the service root, optionally beneath a reverse-proxy path. /// Requests append `api/v1/`; credentials belong in `token`, never the URL. pub fn new(base: &str, token: Secret) -> Result { Self::with_timeout(base, token, REQUEST_TIMEOUT) } fn with_timeout(base: &str, token: Secret, timeout: Duration) -> Result { let invalid = || { Failure::local( "invalid_endpoint", "Enter an HTTPS service URL, or HTTP on localhost/loopback, without credentials, query parameters or a fragment.", ) }; let base = base.trim(); if base.contains('\\') || base.chars().any(char::is_control) || base.split_once("://").is_some_and(|(_, rest)| { rest.split(['/', '?', '#']) .next() .is_some_and(|authority| authority.contains('@')) }) { return Err(invalid()); } let mut base = Url::parse(base).map_err(|_| invalid())?; let host = base.host_str().ok_or_else(invalid)?; let loopback = host.eq_ignore_ascii_case("localhost") || host .trim_matches(['[', ']']) .parse::() .is_ok_and(|ip| ip.is_loopback()); if !(base.scheme() == "https" || (base.scheme() == "http" && loopback)) || !base.username().is_empty() || base.password().is_some() || base.query().is_some() || base.fragment().is_some() { return Err(invalid()); } if !base.path().ends_with('/') { // set_path accepts the existing URL's encoded path without decoding // it; this preserves an optional reverse-proxy prefix exactly. base.set_path(&format!("{}/", base.path())); } let mut headers = header::HeaderMap::new(); headers.insert( header::ACCEPT, header::HeaderValue::from_static("application/json"), ); if !token.0.is_empty() { if !token.0.bytes().all(|byte| byte.is_ascii_graphic()) { return Err(Failure::local( "invalid_token", "The bearer token must contain printable ASCII without whitespace.", )); } let mut value = header::HeaderValue::from_str(&format!("Bearer {}", token.0)).map_err(|_| { Failure::local( "invalid_token", "The bearer token is not a valid HTTP credential.", ) })?; value.set_sensitive(true); headers.insert(header::AUTHORIZATION, value); } let mut builder = Client::builder() .default_headers(headers) .redirect(reqwest::redirect::Policy::none()) .retry(reqwest::retry::never()) .connect_timeout(Duration::from_secs(10).min(timeout)) .timeout(timeout) .user_agent("Arcadia-Studio/hosted-desktop"); if loopback { builder = builder.no_proxy(); } let client = builder.build().map_err(|_| { Failure::local( "client_unavailable", "The HTTP client could not be initialized.", ) })?; Ok(Self { base, client, token, }) } pub fn base(&self) -> &str { self.base.as_str() } fn endpoint(&self, path: &str) -> Url { // All path suffixes are literals or UUIDs, never user-provided paths. self.base .join(&format!("api/v1/{path}")) .expect("validated hierarchical base") } fn redact(&self, text: String) -> String { let text = if self.token.0.is_empty() { text } else { text.replace(&self.token.0, "[redacted]") }; // Limit user-visible diagnostics independently of the response budget. text.chars() .filter(|c| !c.is_control() || matches!(c, '\n' | '\t')) .take(8_192) .collect() } fn invalid_reply(&self, reply: &Reply, mutation: bool, detail: &str) -> Failure { Failure { detail: detail.into(), code: "invalid_response".into(), status: Some(reply.status), uncertain: mutation, published: None, } } fn decode(&self, reply: &Reply, mutation: bool) -> Result { serde_json::from_slice(&reply.body).map_err(|_| { self.invalid_reply( reply, mutation, "The service returned an invalid JSON response. Local work is unchanged.", ) }) } fn etag(&self, reply: &Reply, mutation: bool) -> Result { reply .etag .as_deref() .filter(|value| valid_etag(value)) .map(str::to_owned) .ok_or_else(|| { self.invalid_reply( reply, mutation, "The service did not return a valid strong ETag. Local work is unchanged.", ) }) } async fn request( &self, method: Method, url: Url, bundle: Option, etag: Option<&str>, ) -> Result { let mutation = method != Method::GET; let expected_status = if method == Method::POST { 201 } else { 200 }; let mut request = self.client.request(method, url); if let Some(bundle) = bundle { let bytes = serde_json::to_vec(&bundle).map_err(|_| { Failure::local( "invalid_source_project", "The source project could not be encoded for the service.", ) })?; if bytes.len() > 8 * 1024 * 1024 { return Err(Failure::local( "request_too_large", "The source project exceeds the service's 8 MiB JSON request limit. Your local model remains available; save it locally or reduce the hosted project.", )); } request = request .header(header::CONTENT_TYPE, "application/json") .body(bytes); } if let Some(etag) = etag { if !valid_etag(etag) { return Err(Failure::local( "invalid_etag", "A captured strong server ETag is required to save. Check the server version first.", )); } request = request.header(header::IF_MATCH, etag); } let mut response = request.send().await.map_err(|error| Failure { detail: if error.is_timeout() { "The service request timed out. Check the server before retrying a write." } else { "The service could not be reached or the connection was interrupted. Check the URL, service and credentials." }.into(), code: if error.is_timeout() { "timeout" } else { "connection_failed" }.into(), status: None, uncertain: mutation && !error.is_connect() && !error.is_builder(), published: None, })?; let status = response.status().as_u16(); let limit = if response.status().is_success() { MAX_RESPONSE_BYTES } else { MAX_PROBLEM_BYTES }; let uncertain = mutation && (response.status().is_success() || response.status().is_server_error()); let oversized = || Failure { detail: "The service response exceeds the desktop transport limit. Local work is unchanged." .into(), code: "response_too_large".into(), status: Some(status), uncertain, published: None, }; if response .content_length() .is_some_and(|size| size > limit as u64) { return Err(oversized()); } let etag = response .headers() .get(header::ETAG) .and_then(|value| value.to_str().ok()) .map(str::to_owned); let mut body = Vec::new(); while let Some(chunk) = response.chunk().await.map_err(|_| Failure { detail: "The service response was interrupted. Check the server before retrying a write." .into(), code: "response_interrupted".into(), status: Some(status), uncertain, published: None, })? { if chunk.len() > limit.saturating_sub(body.len()) { return Err(oversized()); } body.extend_from_slice(&chunk); } if status != expected_status { let mut failure = Failure { detail: format!("The service returned HTTP {status}. Local work is unchanged."), code: if (300..400).contains(&status) { "redirect_refused" } else { "http_error" } .into(), status: Some(status), uncertain, published: None, }; if let Ok(problem) = serde_json::from_slice::(&body) { failure.uncertain |= mutation && problem.code == "outcome_unknown"; // These service failures explicitly precede admission/commit. // Other 5xx responses remain uncertain, including storage I/O. if matches!( (status, problem.code.as_str()), (503, "overloaded") | (507, "store_capacity") ) { failure.uncertain = false; } if !problem.code.is_empty() && problem.code.len() <= 80 && problem .code .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') { failure.code = self.redact(problem.code); } let mut detail = problem.detail; for issue in problem.issues.into_iter().take(20) { detail.push('\n'); if let Some(path) = issue.path { detail.push_str(&path); if let Some(line) = issue.line { detail.push_str(&format!(":{line}")); if let Some(column) = issue.column { detail.push_str(&format!(":{column}")); } } detail.push_str(": "); } detail.push_str(&issue.message); } failure.detail = self.redact(detail); } return Err(failure); } Ok(Reply { status, etag, body }) } pub async fn catalog(&self, query: &str, after: Option) -> Result { let mut url = self.endpoint("projects"); { let mut pairs = url.query_pairs_mut(); pairs.append_pair("limit", "100"); if !query.is_empty() { pairs.append_pair("q", query); } if let Some(after) = after { pairs.append_pair("after", &after.to_string()); } } let reply = self.request(Method::GET, url, None, None).await?; let catalog: Catalog = self.decode(&reply, false)?; let mut previous = after; for item in &catalog.items { if item.resource_id.is_nil() || item.model_id.is_nil() || item.revision == 0 || previous.is_some_and(|id| item.resource_id <= id) { return Err(self.invalid_reply( &reply, false, "The service returned an invalid project catalog.", )); } previous = Some(item.resource_id); } if catalog.items.len() > 100 || catalog .next_after .is_some_and(|id| Some(id) != previous || catalog.items.is_empty()) { return Err(self.invalid_reply( &reply, false, "The service returned invalid catalog pagination.", )); } Ok(catalog) } pub async fn open(&self, id: Id) -> Result { let reply = self .request( Method::GET, self.endpoint(&format!("projects/{id}")), None, None, ) .await?; let envelope: Envelope = self.decode(&reply, false)?; let etag = self.etag(&reply, false)?; if envelope.resource_id != id || id.is_nil() || envelope.revision == 0 || envelope.model.id.is_nil() { return Err(self.invalid_reply( &reply, false, "The service returned a different project identity or invalid revision.", )); } let compiled = self.compile(envelope.source_project, &reply, false).await?; if compiled.decoded.project != envelope.model { return Err(self.invalid_reply( &reply, false, "The hosted source and model disagree. The document was not opened.", )); } Ok(Remote { resource_id: id, revision: envelope.revision, etag, compiled, }) } pub async fn publish(&self, bundle: SourceBundle) -> Result { let reply = self .request( Method::POST, self.endpoint("projects/import-source-project"), Some(bundle), None, ) .await?; let receipt: Summary = self.decode(&reply, true)?; if receipt.resource_id.is_nil() || receipt.model_id.is_nil() || receipt.revision == 0 { return Err(self.invalid_reply(&reply, true, "The service returned an invalid publication receipt. Inspect the catalog before publishing again.")); } let published = Some(receipt.resource_id); let etag = self.etag(&reply, true).map_err(|mut failure| { failure.published = published; failure })?; let remote = self.open(receipt.resource_id).await.map_err(|mut failure| { failure.uncertain = true; failure.published = published; failure.detail = format!("Publication was acknowledged, but its source could not be read. Open the published resource before trying another publication.\n{}", failure.detail); failure })?; if remote.revision != receipt.revision || remote.etag != etag || remote.compiled.decoded.project.id != receipt.model_id { let mut failure = self.invalid_reply(&reply, true, "The published resource changed before its source was downloaded. Review the published resource before publishing again."); failure.published = published; return Err(failure); } Ok(remote) } pub async fn save(&self, id: Id, etag: &str, bundle: SourceBundle) -> Result { let reply = self .request( Method::PUT, self.endpoint(&format!("projects/{id}/source-project")), Some(bundle), Some(etag), ) .await?; let accepted: AcceptedSource = self.decode(&reply, true)?; let etag = self.etag(&reply, true)?; if id.is_nil() || accepted.revision == 0 { return Err(self.invalid_reply( &reply, true, "The service returned an invalid saved revision.", )); } let compiled = self.compile(accepted.source_project, &reply, true).await?; Ok(Remote { resource_id: id, revision: accepted.revision, etag, compiled, }) } async fn compile( &self, bundle: SourceBundle, reply: &Reply, mutation: bool, ) -> Result, Failure> { let result = tokio::task::spawn_blocking(move || { let compiled = source_project::compile_bundle(&bundle, SOURCE_LIMITS)?; Ok::<_, Vec>((compiled, bundle)) }) .await .map_err(|_| { self.invalid_reply( reply, mutation, "The hosted source could not be checked. Local work is unchanged.", ) })?; match result { Ok((compiled, bundle)) if compiled.bundle == bundle && !compiled.decoded.project.id.is_nil() => Ok(Arc::new(compiled)), Ok(_) => Err(self.invalid_reply(reply, mutation, "The service returned source without stable current-format identities. Local work is unchanged.")), Err(issues) => { let mut failure = self.invalid_reply(reply, mutation, "The hosted source could not be compiled. Local work is unchanged."); for issue in issues.into_iter().take(10) { failure.detail.push_str(&format!("\n{}:{}:{}: {}", issue.path, issue.diagnostic.line, issue.diagnostic.column, issue.diagnostic.message)); } failure.detail = self.redact(failure.detail); Err(failure) } } } } fn valid_etag(value: &str) -> bool { (2..=1_024).contains(&value.len()) && value.starts_with('"') && value.ends_with('"') && value.as_bytes()[1..value.len() - 1] .iter() .all(|byte| matches!(*byte, 0x21 | 0x23..=0x7e)) } #[cfg(test)] #[path = "client_tests.rs"] mod tests;