503 lines
17 KiB
Rust
503 lines
17 KiB
Rust
//! Bounded Sync v1 transport. No redirects, no token-bearing URLs, no implicit retries.
|
|
use crate::{
|
|
sync_state::{Binding, Job},
|
|
workspace::Workspace,
|
|
};
|
|
use reqwest::{Client, Method, Url};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use std::{
|
|
io::{Read, Seek, SeekFrom},
|
|
sync::{Arc, Mutex},
|
|
time::Duration,
|
|
};
|
|
use zeroize::Zeroizing;
|
|
|
|
#[derive(Debug)]
|
|
pub struct SyncError {
|
|
pub code: String,
|
|
pub status: u16,
|
|
pub retry_after: Option<u64>,
|
|
}
|
|
impl SyncError {
|
|
fn new(code: &str) -> Self {
|
|
Self {
|
|
code: code.into(),
|
|
status: 0,
|
|
retry_after: None,
|
|
}
|
|
}
|
|
}
|
|
type Result<T> = std::result::Result<T, SyncError>;
|
|
pub trait WorkspaceAccess: Send + Sync {
|
|
fn access<T>(
|
|
&self,
|
|
action: impl FnOnce(&mut Workspace) -> crate::workspace::Result<T>,
|
|
) -> Result<T>;
|
|
}
|
|
impl WorkspaceAccess for Arc<Mutex<Workspace>> {
|
|
fn access<T>(
|
|
&self,
|
|
action: impl FnOnce(&mut Workspace) -> crate::workspace::Result<T>,
|
|
) -> Result<T> {
|
|
action(&mut *self.lock().map_err(|_| SyncError::new("HOST_BUSY"))?).map_err(Into::into)
|
|
}
|
|
}
|
|
impl WorkspaceAccess for Arc<Mutex<Option<Workspace>>> {
|
|
fn access<T>(
|
|
&self,
|
|
action: impl FnOnce(&mut Workspace) -> crate::workspace::Result<T>,
|
|
) -> Result<T> {
|
|
action(
|
|
self.lock()
|
|
.map_err(|_| SyncError::new("HOST_BUSY"))?
|
|
.as_mut()
|
|
.ok_or_else(|| SyncError::new("WORKSPACE_NOT_OPEN"))?,
|
|
)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
impl From<crate::workspace::HostError> for SyncError {
|
|
fn from(value: crate::workspace::HostError) -> Self {
|
|
Self::new(&value.code)
|
|
}
|
|
}
|
|
impl From<std::io::Error> for SyncError {
|
|
fn from(_: std::io::Error) -> Self {
|
|
Self::new("SYNC_IO_FAILED")
|
|
}
|
|
}
|
|
|
|
/// Persist only via the Stronghold Sync scope, never as an IPC response.
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct Session {
|
|
pub access_token: String,
|
|
pub refresh_token: String,
|
|
pub expires_in: u64,
|
|
pub device_id: String,
|
|
}
|
|
impl Drop for Session {
|
|
fn drop(&mut self) {
|
|
use zeroize::Zeroize;
|
|
self.access_token.zeroize();
|
|
self.refresh_token.zeroize();
|
|
}
|
|
}
|
|
pub struct SyncClient {
|
|
endpoint: Url,
|
|
client: Client,
|
|
token: Zeroizing<String>,
|
|
}
|
|
|
|
impl SyncClient {
|
|
pub fn new(endpoint: &str, token: Zeroizing<String>, allow_test_http: bool) -> Result<Self> {
|
|
let mut url = Url::parse(endpoint).map_err(|_| SyncError::new("SYNC_ENDPOINT_INVALID"))?;
|
|
if (url.scheme() != "https" && !(allow_test_http && url.scheme() == "http"))
|
|
|| url.host_str().is_none()
|
|
|| !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
|| url.query().is_some()
|
|
|| url.fragment().is_some()
|
|
|| !matches!(url.path(), "" | "/")
|
|
{
|
|
return Err(SyncError::new("SYNC_ENDPOINT_INVALID"));
|
|
}
|
|
url.set_path("/");
|
|
let client = Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.map_err(|_| SyncError::new("SYNC_CLIENT_FAILED"))?;
|
|
Ok(Self {
|
|
endpoint: url,
|
|
client,
|
|
token,
|
|
})
|
|
}
|
|
pub async fn json(&self, method: Method, path: &str, body: Option<Value>) -> Result<Value> {
|
|
self.send(method, path, body.map(|v| v.to_string().into_bytes()), true)
|
|
.await
|
|
}
|
|
async fn send(
|
|
&self,
|
|
method: Method,
|
|
path: &str,
|
|
body: Option<Vec<u8>>,
|
|
is_json: bool,
|
|
) -> Result<Value> {
|
|
if !path.starts_with("sync/v1/") || path.contains(['\\', '#']) || path.contains("..") {
|
|
return Err(SyncError::new("SYNC_PATH_INVALID"));
|
|
}
|
|
let url = self
|
|
.endpoint
|
|
.join(path)
|
|
.map_err(|_| SyncError::new("SYNC_PATH_INVALID"))?;
|
|
let mut request = self.client.request(method, url);
|
|
if !self.token.is_empty() {
|
|
request = request.bearer_auth(self.token.as_str());
|
|
}
|
|
if let Some(body) = body {
|
|
request = request
|
|
.header(
|
|
"Content-Type",
|
|
if is_json {
|
|
"application/json"
|
|
} else {
|
|
"application/octet-stream"
|
|
},
|
|
)
|
|
.body(body);
|
|
}
|
|
let mut response = request
|
|
.send()
|
|
.await
|
|
.map_err(|_| SyncError::new("SYNC_NETWORK_ERROR"))?;
|
|
let status = response.status().as_u16();
|
|
let retry_after = response
|
|
.headers()
|
|
.get("retry-after")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| v.parse().ok());
|
|
let mut body = Vec::new();
|
|
while let Some(chunk) = response
|
|
.chunk()
|
|
.await
|
|
.map_err(|_| SyncError::new("SYNC_NETWORK_ERROR"))?
|
|
{
|
|
if body.len() + chunk.len() > 4 * 1024 * 1024 {
|
|
return Err(SyncError::new("SYNC_RESPONSE_TOO_LARGE"));
|
|
}
|
|
body.extend_from_slice(&chunk);
|
|
}
|
|
let value: Value = if body.is_empty() {
|
|
Value::Null
|
|
} else {
|
|
serde_json::from_slice(&body).map_err(|_| SyncError::new("SYNC_RESPONSE_INVALID"))?
|
|
};
|
|
if !(200..300).contains(&status) {
|
|
let code = value["error"]["code"]
|
|
.as_str()
|
|
.filter(|v| v.len() <= 80 && v.bytes().all(|b| b.is_ascii_uppercase() || b == b'_'))
|
|
.unwrap_or("SYNC_HTTP_ERROR");
|
|
return Err(SyncError {
|
|
code: code.into(),
|
|
status,
|
|
retry_after,
|
|
});
|
|
}
|
|
Ok(value)
|
|
}
|
|
pub async fn login(
|
|
&self,
|
|
username: &str,
|
|
password: Zeroizing<String>,
|
|
device_name: &str,
|
|
) -> Result<Session> {
|
|
let value = self.json(Method::POST, "sync/v1/auth/sessions", Some(json!({"username":username,"password":password.as_str(),"device_name":device_name}))).await?;
|
|
serde_json::from_value(value).map_err(|_| SyncError::new("SYNC_RESPONSE_INVALID"))
|
|
}
|
|
pub async fn handshake(&self) -> Result<()> {
|
|
let result = self
|
|
.json(Method::GET, "sync/v1/handshake?protocol=1", None)
|
|
.await?;
|
|
if result["protocol"] != 1
|
|
|| result["chunk_size"] != 1048576
|
|
|| result["max_object_size"] != 104857600
|
|
{
|
|
return Err(SyncError::new("PROTOCOL_INCOMPATIBLE"));
|
|
}
|
|
Ok(())
|
|
}
|
|
pub async fn verify_empty(&self, remote_vault: &str) -> Result<()> {
|
|
identifier(remote_vault)?;
|
|
let page = self
|
|
.json(
|
|
Method::GET,
|
|
&format!("sync/v1/vaults/{remote_vault}/changes?limit=1"),
|
|
None,
|
|
)
|
|
.await?;
|
|
if page["boundary"] != 0 || page["items"].as_array().is_none_or(|v| !v.is_empty()) {
|
|
return Err(SyncError::new("SYNC_RECONCILIATION_REQUIRED"));
|
|
}
|
|
Ok(())
|
|
}
|
|
pub async fn push_one(
|
|
&self,
|
|
workspace: &impl WorkspaceAccess,
|
|
binding: &Binding,
|
|
) -> Result<bool> {
|
|
if Url::parse(&binding.endpoint)
|
|
.ok()
|
|
.is_none_or(|url| url != self.endpoint)
|
|
{
|
|
return Err(SyncError::new("SYNC_BINDING_CHANGED"));
|
|
}
|
|
identifier(&binding.remote_vault)?;
|
|
let job = workspace.access(|ws| {
|
|
ws.sync_resume_resolutions(&binding.id)?;
|
|
ws.sync_capture(&binding.id)?;
|
|
ws.sync_next(&binding.id)
|
|
})?;
|
|
let Some(job) = job else {
|
|
return Ok(false);
|
|
};
|
|
if job.state == "conflict" {
|
|
return Err(SyncError::new("REVISION_CONFLICT"));
|
|
}
|
|
if job.operation == "put" && job.base_revision.is_none() {
|
|
self.upload(workspace, binding, &job).await?;
|
|
}
|
|
let payload = workspace.access(|ws| ws.sync_commit_payload(&job))?;
|
|
let revision = self
|
|
.json(
|
|
Method::POST,
|
|
&format!("sync/v1/vaults/{}/revisions", binding.remote_vault),
|
|
Some(payload),
|
|
)
|
|
.await?;
|
|
workspace.access(|ws| ws.sync_ack(&job, &revision))?;
|
|
Ok(true)
|
|
}
|
|
pub async fn pull_page(
|
|
&self,
|
|
workspace: &impl WorkspaceAccess,
|
|
binding: &Binding,
|
|
) -> Result<usize> {
|
|
if Url::parse(&binding.endpoint)
|
|
.ok()
|
|
.is_none_or(|url| url != self.endpoint)
|
|
{
|
|
return Err(SyncError::new("SYNC_BINDING_CHANGED"));
|
|
}
|
|
identifier(&binding.remote_vault)?;
|
|
workspace.access(|ws| {
|
|
ws.sync_resume_resolutions(&binding.id)?;
|
|
while ws.sync_apply_pending(&binding.id)? {}
|
|
Ok(())
|
|
})?;
|
|
let (cursor, boundary) = workspace.access(|ws| {
|
|
ws.check_binding(&binding.id)?;
|
|
Ok((
|
|
ws.sync_binding()?
|
|
.ok_or_else(|| crate::workspace::HostError::new("SYNC_BINDING_CHANGED"))?
|
|
.cursor,
|
|
ws.sync_boundary(&binding.id)?,
|
|
))
|
|
})?;
|
|
let mut path = format!(
|
|
"sync/v1/vaults/{}/changes?cursor={cursor}&limit=100",
|
|
binding.remote_vault
|
|
);
|
|
if let Some(end) = boundary {
|
|
path.push_str(&format!("&boundary={end}"));
|
|
}
|
|
let page = self.json(Method::GET, &path, None).await?;
|
|
let end = page["boundary"]
|
|
.as_i64()
|
|
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
|
|
let items = page["items"]
|
|
.as_array()
|
|
.filter(|items| items.len() <= 100)
|
|
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
|
|
if items.is_empty() {
|
|
if end != cursor {
|
|
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
|
|
}
|
|
return Ok(0);
|
|
}
|
|
workspace.access(|ws| ws.sync_set_boundary(&binding.id, end))?;
|
|
for (index, item) in items.iter().enumerate() {
|
|
let revision: crate::sync_inbox::RemoteRevision = serde_json::from_value(item.clone())
|
|
.map_err(|_| SyncError::new("SYNC_RESPONSE_INVALID"))?;
|
|
revision.validate(binding)?;
|
|
if revision.sequence != cursor + index as i64 + 1 || revision.sequence > end {
|
|
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
|
|
}
|
|
if revision.operation == "put" {
|
|
self.download(workspace, binding, &revision).await?;
|
|
}
|
|
workspace.access(|ws| {
|
|
ws.sync_stage(&binding.id, &revision)?;
|
|
ws.sync_apply_pending(&binding.id)?;
|
|
Ok(())
|
|
})?;
|
|
}
|
|
Ok(items.len())
|
|
}
|
|
async fn download(
|
|
&self,
|
|
workspace: &impl WorkspaceAccess,
|
|
binding: &Binding,
|
|
revision: &crate::sync_inbox::RemoteRevision,
|
|
) -> Result<()> {
|
|
let digest = revision
|
|
.hash
|
|
.as_deref()
|
|
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
|
|
let target = workspace.access(|ws| {
|
|
ws.check_binding(&binding.id)?;
|
|
ws.sync_spool(digest)
|
|
})?;
|
|
if target.exists() {
|
|
let bytes = std::fs::read(&target)?;
|
|
if bytes.len() as i64 == revision.size && crate::workspace::hash(&bytes) == digest {
|
|
return Ok(());
|
|
}
|
|
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
|
|
}
|
|
let url = self
|
|
.endpoint
|
|
.join(&format!(
|
|
"sync/v1/vaults/{}/objects/{digest}",
|
|
binding.remote_vault
|
|
))
|
|
.map_err(|_| SyncError::new("SYNC_PATH_INVALID"))?;
|
|
let mut response = self
|
|
.client
|
|
.get(url)
|
|
.bearer_auth(self.token.as_str())
|
|
.send()
|
|
.await
|
|
.map_err(|_| SyncError::new("SYNC_NETWORK_ERROR"))?;
|
|
if !response.status().is_success() {
|
|
return Err(SyncError {
|
|
code: "SYNC_DOWNLOAD_FAILED".into(),
|
|
status: response.status().as_u16(),
|
|
retry_after: None,
|
|
});
|
|
}
|
|
let mut file = tempfile::NamedTempFile::new_in(
|
|
target
|
|
.parent()
|
|
.ok_or_else(|| SyncError::new("SYNC_SPOOL_FAILED"))?,
|
|
)?;
|
|
let mut hasher = Sha256::new();
|
|
let mut length = 0u64;
|
|
while let Some(chunk) = response
|
|
.chunk()
|
|
.await
|
|
.map_err(|_| SyncError::new("SYNC_NETWORK_ERROR"))?
|
|
{
|
|
length += chunk.len() as u64;
|
|
if length > revision.size as u64 {
|
|
return Err(SyncError::new("SYNC_OBJECT_CORRUPT"));
|
|
}
|
|
workspace.access(|ws| ws.check_binding(&binding.id))?;
|
|
std::io::Write::write_all(&mut file, &chunk)?;
|
|
hasher.update(&chunk);
|
|
}
|
|
if length != revision.size as u64 || format!("{:x}", hasher.finalize()) != digest {
|
|
return Err(SyncError::new("SYNC_OBJECT_CORRUPT"));
|
|
}
|
|
file.as_file().sync_all()?;
|
|
file.persist_noclobber(target)
|
|
.map_err(|_| SyncError::new("SYNC_SPOOL_FAILED"))?;
|
|
Ok(())
|
|
}
|
|
async fn upload(
|
|
&self,
|
|
workspace: &impl WorkspaceAccess,
|
|
binding: &Binding,
|
|
job: &Job,
|
|
) -> Result<()> {
|
|
let path = workspace.access(|ws| ws.sync_spool(&job.hash))?;
|
|
let mut file = std::fs::File::open(path)?;
|
|
if file.metadata()?.len() != job.size as u64 {
|
|
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
|
|
}
|
|
let mut hasher = Sha256::new();
|
|
let mut buffer = vec![0u8; 1048576];
|
|
loop {
|
|
let count = file.read(&mut buffer)?;
|
|
if count == 0 {
|
|
break;
|
|
}
|
|
hasher.update(&buffer[..count]);
|
|
}
|
|
if format!("{:x}", hasher.finalize()) != job.hash {
|
|
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
|
|
}
|
|
let base = format!("sync/v1/vaults/{}/uploads", binding.remote_vault);
|
|
let mut upload_id = job.upload_id.clone();
|
|
let mut offset = 0;
|
|
if let Some(id) = &upload_id {
|
|
identifier(id)?;
|
|
match self.json(Method::GET, &format!("{base}/{id}"), None).await {
|
|
Ok(status) => {
|
|
offset = status["offset"]
|
|
.as_u64()
|
|
.filter(|v| *v <= job.size as u64)
|
|
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?
|
|
}
|
|
Err(error)
|
|
if matches!(error.code.as_str(), "UPLOAD_EXPIRED" | "UPLOAD_DAMAGED") =>
|
|
{
|
|
upload_id = None
|
|
}
|
|
Err(error) => return Err(error),
|
|
}
|
|
}
|
|
if upload_id.is_none() {
|
|
let response = self
|
|
.json(
|
|
Method::POST,
|
|
&base,
|
|
Some(json!({"content_hash":job.hash,"size":job.size})),
|
|
)
|
|
.await?;
|
|
if response["complete"] == true {
|
|
return Ok(());
|
|
}
|
|
upload_id = Some(
|
|
response["upload_id"]
|
|
.as_str()
|
|
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?
|
|
.into(),
|
|
);
|
|
workspace.access(|ws| ws.sync_upload(job, upload_id.as_deref()))?;
|
|
}
|
|
let id = upload_id.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
|
|
identifier(&id)?;
|
|
file.seek(SeekFrom::Start(offset))?;
|
|
while offset < job.size as u64 {
|
|
workspace.access(|ws| ws.check_binding(&binding.id))?;
|
|
let count = file.read(&mut buffer)?;
|
|
if count == 0 {
|
|
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
|
|
}
|
|
let value = self
|
|
.send(
|
|
Method::PUT,
|
|
&format!("{base}/{id}?offset={offset}"),
|
|
Some(buffer[..count].to_vec()),
|
|
false,
|
|
)
|
|
.await?;
|
|
if value["offset"].as_u64() != Some(offset + count as u64) {
|
|
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
|
|
}
|
|
offset += count as u64;
|
|
}
|
|
let value = self
|
|
.json(Method::POST, &format!("{base}/{id}/complete"), None)
|
|
.await?;
|
|
if value["complete"] != true || value["content_hash"] != job.hash {
|
|
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
fn identifier(value: &str) -> Result<()> {
|
|
if value.is_empty()
|
|
|| value.len() > 80
|
|
|| !value
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
|
{
|
|
return Err(SyncError::new("SYNC_IDENTIFIER_INVALID"));
|
|
}
|
|
Ok(())
|
|
}
|