feat: 添加持久化 Rust Sync 上传队列与 HTTP 传输

This commit is contained in:
2026-09-08 15:25:49 +08:00
parent e9df45b7ca
commit 2bc9ef84b9
8 changed files with 851 additions and 7 deletions
+365
View File
@@ -0,0 +1,365 @@
//! 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_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)
}
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(())
}