Files
NotesAgentic/frontend/src-tauri/src/sync_client.rs
T
admin d703ab64e3
CI / docs-check (push) Canceled after 0s
CI / backend-test (push) Canceled after 0s
CI / service-test (push) Canceled after 0s
CI / frontend-test (push) Canceled after 0s
CI / rust-core (push) Canceled after 0s
CI / docs-check (pull_request) Canceled after 0s
CI / backend-test (pull_request) Canceled after 0s
CI / service-test (pull_request) Canceled after 0s
CI / frontend-test (pull_request) Canceled after 0s
CI / rust-core (pull_request) Canceled after 0s
docs: 将仓库代码注释统一为中文
2026-09-10 00:40:56 +08:00

637 lines
22 KiB
Rust

//! 有界同步 v1 传输。没有重定向,没有带有令牌的 URL,没有隐式重试。
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 {
pub(crate) fn new(code: &str) -> Self {
Self {
code: code.into(),
status: 0,
retry_after: None,
}
}
}
type Result<T> = std::result::Result<T, SyncError>;
struct Attempt<'a, W: WorkspaceAccess> {
workspace: &'a W,
job: &'a Job,
finished: bool,
}
impl<W: WorkspaceAccess> Drop for Attempt<'_, W> {
fn drop(&mut self) {
if !self.finished {
let _ = self
.workspace
.access(|ws| ws.sync_attempt_interrupt(self.job));
}
}
}
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")
}
}
/// 仅通过 Stronghold Sync 范围持续,绝不作为 IPC 响应。
#[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 fn endpoint(&self) -> &str {
self.endpoint.as_str()
}
pub async fn refresh(&self, refresh_token: &str) -> Result<Session> {
let value = self
.json(
Method::POST,
"sync/v1/auth/refresh",
Some(json!({"refresh_token":refresh_token})),
)
.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 snapshot(&self, remote_vault: &str) -> Result<crate::sync_initial::Snapshot> {
identifier(remote_vault)?;
let mut cursor = 0i64;
let mut boundary = None;
let mut heads = std::collections::BTreeMap::new();
loop {
let mut path =
format!("sync/v1/vaults/{remote_vault}/changes?cursor={cursor}&limit=500");
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"))?;
if end < cursor || boundary.is_some_and(|old| old != end) || end > 100000 {
return Err(SyncError::new("SYNC_SNAPSHOT_LIMIT"));
}
boundary = Some(end);
let items = page["items"]
.as_array()
.filter(|v| v.len() <= 500)
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
if items.is_empty() && cursor != end {
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
}
for item in items {
let revision: crate::sync_inbox::RemoteRevision =
serde_json::from_value(item.clone())
.map_err(|_| SyncError::new("SYNC_RESPONSE_INVALID"))?;
if revision.sequence != cursor + 1 || revision.sequence > end {
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
}
cursor = revision.sequence;
heads.insert(revision.file_id.clone(), revision);
}
if cursor == end {
return Ok(crate::sync_initial::Snapshot {
boundary: end,
items: heads.into_values().collect(),
});
}
}
}
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)?;
if workspace
.access(|ws| ws.sync_initial_pending(&binding.id))?
.is_some()
{
return Ok(false);
}
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"));
}
workspace.access(|ws| ws.sync_attempt_start(&job))?;
let mut attempt = Attempt {
workspace,
job: &job,
finished: false,
};
let result = async {
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)
}
.await;
workspace.access(|ws| {
ws.sync_attempt_finish(
&job,
result.as_ref().err().map(|e: &SyncError| e.code.as_str()),
)
})?;
attempt.finished = true;
result
}
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(())
})?;
if let Some(initial) = workspace.access(|ws| ws.sync_initial_pending(&binding.id))? {
let count = initial.len().min(100);
for revision in initial.iter().take(count) {
if revision.operation == "put"
&& workspace.access(|ws| ws.sync_path_enabled(&revision.path))?
{
self.download(workspace, binding, revision).await?;
}
workspace.access(|ws| {
ws.sync_stage(&binding.id, revision)?;
ws.sync_apply_pending(&binding.id)?;
Ok(())
})?;
}
return Ok(count);
}
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"
&& workspace.access(|ws| ws.sync_path_enabled(&revision.path))?
{
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() {
crate::payloads::verify(&target, digest, revision.size as u64)?;
workspace.access(|ws| ws.check_binding(&binding.id))?;
return Ok(());
}
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 = crate::payloads::open_verified(&path, &job.hash, job.size as u64)?;
workspace.access(|ws| ws.check_binding(&binding.id))?;
let mut buffer = vec![0u8; 1048576];
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(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn dropping_a_pending_attempt_marks_interruption_without_restart() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();
ws.write("cancel.md", "", b"retained", "local").unwrap();
let binding = ws
.sync_bind_empty("https://sync.example", "remote", "account")
.unwrap();
let job = ws.sync_next(&binding.id).unwrap().unwrap();
let workspace = Arc::new(Mutex::new(ws));
let future = async {
workspace.access(|ws| ws.sync_attempt_start(&job)).unwrap();
let _attempt = Attempt {
workspace: &workspace,
job: &job,
finished: false,
};
std::future::pending::<()>().await;
};
assert!(tokio::time::timeout(Duration::from_millis(10), future)
.await
.is_err());
let rows = workspace
.access(|ws| ws.sync_attempts(&binding.id))
.unwrap();
assert_eq!(rows[0]["outcome"], "interrupted");
assert_eq!(rows[0]["attempts"], 1);
assert_eq!(
workspace.access(|ws| ws.read("cancel.md")).unwrap().content,
"retained"
);
}
}