feat: 添加持久化 Rust Sync 上传队列与 HTTP 传输
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user