622 lines
21 KiB
Rust
622 lines
21 KiB
Rust
//! 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<CompiledSourceProject>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct Catalog {
|
|
pub items: Vec<Summary>,
|
|
pub next_after: Option<Id>,
|
|
}
|
|
|
|
#[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<u16>,
|
|
/// 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<Id>,
|
|
}
|
|
|
|
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<ProblemIssue>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ProblemIssue {
|
|
message: String,
|
|
path: Option<String>,
|
|
line: Option<usize>,
|
|
column: Option<usize>,
|
|
}
|
|
|
|
struct Reply {
|
|
status: u16,
|
|
etag: Option<String>,
|
|
body: Vec<u8>,
|
|
}
|
|
|
|
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, Failure> {
|
|
Self::with_timeout(base, token, REQUEST_TIMEOUT)
|
|
}
|
|
|
|
fn with_timeout(base: &str, token: Secret, timeout: Duration) -> Result<Self, Failure> {
|
|
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::<IpAddr>()
|
|
.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<T: DeserializeOwned>(&self, reply: &Reply, mutation: bool) -> Result<T, Failure> {
|
|
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<String, Failure> {
|
|
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<SourceBundle>,
|
|
etag: Option<&str>,
|
|
) -> Result<Reply, Failure> {
|
|
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::<Problem>(&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<Id>) -> Result<Catalog, Failure> {
|
|
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<Remote, Failure> {
|
|
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<Remote, Failure> {
|
|
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<Remote, Failure> {
|
|
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<Arc<CompiledSourceProject>, Failure> {
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
let compiled = source_project::compile_bundle(&bundle, SOURCE_LIMITS)?;
|
|
Ok::<_, Vec<source_project::SourceDiagnostic>>((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;
|