Sync with the server and scan-to-join onboarding
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

- Device key (DeviceSigner): Windows TPM via CNG with a DPAPI-sealed software
  fallback, Linux Secret Service with a 0600 file fallback (warned in Settings),
  macOS keychain for dev runs. The store used is recorded; no silent switching.
- SyncService (Tauri-free): server URL and pins, create account with the
  server's setup code, sync after unlock/save/browser save, every 15 min while
  unlocked and on demand, status events, integrity alarm, device list with
  fingerprint approval and revoke.
- Onboarding: "Use my phone" (join_start/poll, QR rendered locally, 6-digit
  code), Emergency Kit from PDF/PNG/JPEG (hayro + rqrr), paste setup code, new
  account. Then only the master password.
- From a signed-in desktop: "Add a phone or tablet" QR with countdown (cancel,
  lock and quit revoke the ticket) and "Approve a computer" via pasted join code.
- KESTROVE_DATA_DIR / KESTROVE_DEVICE_NAME for a second instance; ignored
  live test drives three instances against a real server.
- deny: ignore three unmaintained notices for zbus 5 sub-crates (Linux keyring).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 07:51:06 +09:00
co-authored by Claude Opus 5.5
parent 7701b65087
commit e19370f261
13 changed files with 3532 additions and 116 deletions
+670
View File
@@ -0,0 +1,670 @@
//! Sync server connection and onboarding (docs/03, docs/10) on top of kv-ffi, without Tauri types so the
//! integration tests can drive it. The UI layer (main.rs) wraps each method in a command.
//!
//! Secrets stay here: setup payloads (Secret Key, ticket) never go to the web view except as the QR image the
//! user asked to see.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use kv_ffi::{CoreError, DeviceDto, LoginResult, PlatformDto, SetupInfo, VaultCore};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::device_key::DeviceKey;
use crate::{private_file, qr};
/// Background sync while unlocked (docs/03 "Triggers").
pub const SYNC_PERIOD: Duration = Duration::from_secs(15 * 60);
/// Saves in quick succession (or an unlock right after a save) become one sync.
const COALESCE: Duration = Duration::from_millis(800);
/// What the UI shows about sync. Sent with every change (event `sync-status`).
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct SyncStatus {
pub server_url: Option<String>,
pub pins: Vec<String>,
pub logged_in: bool,
pub syncing: bool,
/// Unix seconds of the last successful sync.
pub last_sync_at: Option<u64>,
pub last_error: Option<String>,
/// This device waits for approval on another device.
pub pending_approval: bool,
/// Other devices waiting for this user's approval.
pub pending_devices: u32,
/// The server failed verification; syncing that vault has stopped (docs/03 "Integrity").
pub integrity_alarm: Option<String>,
pub device_fingerprint: Option<String>,
pub key_storage: Option<String>,
pub key_warning: Option<String>,
/// Items an edit here brought back after a delete elsewhere, from the last sync.
pub restored_items: u32,
/// Bumped whenever a sync changed local items, so the UI knows to refresh the list.
pub changes: u64,
}
/// What we persist besides the core's own records (`sync.json`, 0600).
#[derive(Default, Serialize, Deserialize)]
struct Saved {
server_url: Option<String>,
#[serde(default)]
pins: Vec<String>,
last_sync_at: Option<u64>,
integrity_alarm: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeviceView {
pub device_id: String,
pub name: String,
pub platform: String,
pub approved: bool,
pub revoked: bool,
pub current: bool,
pub created_at: String,
pub key_fingerprint: String,
}
/// A setup payload waiting for the master password. The Secret Key stays in Rust.
#[derive(Debug, Clone, Serialize)]
pub struct SetupView {
pub server_url: String,
pub server_host: String,
pub account_id: String,
/// A ticket means no manual approval will be needed.
pub preapproved: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct JoinView {
/// QR image (`data:` URI) to scan with a signed-in phone.
pub qr_svg: String,
/// The same as text, for "Paste a computer's join code" on another computer.
pub qr_text: String,
pub expires_at: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct JoinPollView {
/// `"123 456"` once a phone scanned the QR.
pub code: Option<String>,
/// Set when the bundle arrived: ask for the master password and call `finish_setup`.
pub setup: Option<SetupView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AddDeviceView {
pub qr_svg: String,
pub expires_at: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct JoinConfirmView {
pub code: String,
pub device_name: String,
pub platform: String,
pub key_fingerprint: String,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SignIn {
Ok,
PendingApproval,
}
type Notify = Box<dyn Fn(&SyncStatus) + Send + Sync>;
pub struct SyncService {
core: Arc<VaultCore>,
data_dir: PathBuf,
key: Result<Arc<DeviceKey>, String>,
status: Mutex<SyncStatus>,
/// From "Use my phone", the Emergency Kit or a pasted code, until `finish_setup` succeeds.
pending_setup: Mutex<Option<SetupInfo>>,
/// The "Add a device" ticket on screen, revoked on cancel and on lock.
add_device_ticket: Mutex<Option<String>>,
wake: tokio::sync::Notify,
notify: Notify,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// User-facing text for a core error.
pub fn describe(e: &CoreError) -> String {
match e {
CoreError::Network { msg } => format!("Can't reach the server: {msg}"),
CoreError::PinMismatch => {
"The server's certificate doesn't match the pinned keys. Someone may be intercepting \
the connection, so Kestrove won't talk to it."
.into()
}
CoreError::Server { status: 429, .. } => "Too many attempts. Wait a minute and try again.".into(),
CoreError::BadCredentials => "Wrong master password or Secret Key.".into(),
CoreError::PendingApproval => "This computer is waiting for approval on one of your other devices.".into(),
CoreError::NotConfigured => "No sync server is set up yet.".into(),
CoreError::NotAuthenticated => "Sign in to the sync server again (lock and unlock with your password).".into(),
CoreError::IntegrityAlarm { vault_id, reason } => format!(
"The server sent vault data that failed verification (vault {vault_id}: {reason}). Syncing stopped; \
your local data is unchanged."
),
CoreError::Invalid { msg } => msg.clone(),
other => other.to_string(),
}
}
fn platform_name(p: PlatformDto) -> &'static str {
match p {
PlatformDto::Ios => "iOS",
PlatformDto::Macos => "macOS",
PlatformDto::Android => "Android",
PlatformDto::Windows => "Windows",
PlatformDto::Linux => "Linux",
}
}
fn this_platform() -> PlatformDto {
if cfg!(windows) {
PlatformDto::Windows
} else if cfg!(target_os = "macos") {
PlatformDto::Macos
} else {
PlatformDto::Linux
}
}
/// The computer's name for the device list ("Sami's PC"); `KESTROVE_DEVICE_NAME` overrides it.
pub fn device_name() -> String {
if let Ok(n) = std::env::var("KESTROVE_DEVICE_NAME") {
return n;
}
#[cfg(windows)]
let host = std::env::var("COMPUTERNAME").ok();
#[cfg(unix)]
let host = {
let mut buf = [0u8; 256];
// SAFETY: the buffer is valid for its length; gethostname NUL-terminates on success (we also cap it).
let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len() - 1) };
(rc == 0).then(|| {
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..end]).trim_end_matches(".local").to_string()
})
};
host.filter(|h| !h.is_empty()).unwrap_or_else(|| format!("{} computer", platform_name(this_platform())))
}
fn host_of(url: &str) -> String {
url::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string)).unwrap_or_else(|| url.to_string())
}
fn setup_view(s: &SetupInfo) -> SetupView {
SetupView {
server_url: s.server_url.clone(),
server_host: host_of(&s.server_url),
account_id: s.account_id.clone(),
preapproved: s.ticket.is_some(),
}
}
fn device_view(d: DeviceDto) -> DeviceView {
DeviceView {
device_id: d.device_id,
name: d.name,
platform: platform_name(d.platform).into(),
approved: d.approved,
revoked: d.revoked,
current: d.current,
created_at: d.created_at,
key_fingerprint: d.key_fingerprint,
}
}
/// Splits "a, b c" pin lists typed by the user.
pub fn parse_pins(text: &str) -> Vec<String> {
text.split(|c: char| c == ',' || c.is_whitespace()).filter(|s| !s.is_empty()).map(str::to_string).collect()
}
impl SyncService {
/// `key` is this device's signing key (or why it could not be loaded; sync is then unavailable).
pub fn new(core: Arc<VaultCore>, data_dir: &Path, key: Result<DeviceKey, String>, notify: Notify) -> Arc<Self> {
let saved: Saved = private_file::read(&data_dir.join("sync.json"))
.ok()
.flatten()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let key = key.map(Arc::new);
let mut status = SyncStatus {
server_url: saved.server_url,
pins: saved.pins,
last_sync_at: saved.last_sync_at,
integrity_alarm: saved.integrity_alarm,
..Default::default()
};
match &key {
Ok(k) => {
status.key_storage = Some(k.storage().describe().into());
status.key_warning = k.warning().map(str::to_string);
let signer: Arc<dyn kv_ffi::DeviceSigner> = k.clone();
if let Err(e) = core.set_device_signer(signer, device_name(), this_platform()) {
status.key_warning = Some(format!("Sync is unavailable: {}", describe(&e)));
}
status.device_fingerprint = core.device_key_fingerprint().ok();
}
Err(e) => {
status.key_warning = Some(format!("Sync is unavailable: this device's key could not be loaded ({e})."))
}
}
Arc::new(Self {
core,
data_dir: data_dir.to_path_buf(),
key,
status: Mutex::new(status),
pending_setup: Mutex::new(None),
add_device_ticket: Mutex::new(None),
wake: tokio::sync::Notify::new(),
notify,
})
}
pub fn core(&self) -> &Arc<VaultCore> {
&self.core
}
pub fn status(&self) -> SyncStatus {
let mut s = lock(&self.status).clone();
s.logged_in = self.core.is_logged_in();
s
}
fn update(&self, f: impl FnOnce(&mut SyncStatus)) {
let snapshot = {
let mut s = lock(&self.status);
f(&mut s);
let saved = Saved {
server_url: s.server_url.clone(),
pins: s.pins.clone(),
last_sync_at: s.last_sync_at,
integrity_alarm: s.integrity_alarm.clone(),
};
if let Ok(bytes) = serde_json::to_vec(&saved) {
if let Err(e) = private_file::write(&self.data_dir.join("sync.json"), &bytes) {
eprintln!("kestrove: could not save sync settings: {e}");
}
}
s.clone()
};
let mut snapshot = snapshot;
snapshot.logged_in = self.core.is_logged_in();
(self.notify)(&snapshot);
}
fn require_key(&self) -> Result<(), String> {
self.key.as_ref().map(|_| ()).map_err(|e| format!("Sync is unavailable: {e}"))
}
// ------------------------------------------------------------ Secret Key on this device
fn secret_key_path(&self) -> PathBuf {
self.data_dir.join("secret-key")
}
pub fn remember_secret_key(&self, secret_key: &str) {
if let Err(e) = private_file::write(&self.secret_key_path(), secret_key.trim().as_bytes()) {
eprintln!("kestrove: could not store the Secret Key: {e}");
}
}
pub fn stored_secret_key(&self) -> Option<Zeroizing<String>> {
let b = private_file::read(&self.secret_key_path()).ok()??;
String::from_utf8(b).ok().map(Zeroizing::new)
}
// ------------------------------------------------------------ server settings and sign-in
/// Saves the server URL and optional SPKI pins (base64 SHA-256).
pub fn configure(&self, url: &str, pins: Vec<String>) -> Result<(), String> {
self.require_key()?;
let url = url.trim().trim_end_matches('/').to_string();
self.core.configure_server(url.clone(), pins.clone()).map_err(|e| describe(&e))?;
self.update(|s| {
s.server_url = Some(url);
s.pins = pins;
});
Ok(())
}
/// "Create a new account": creates the vault here and registers it with a fresh server using the setup code
/// (`KV_SETUP_CODE`). Returns the Secret Key for the Emergency Kit.
pub async fn create_account(
&self,
server_url: &str,
pins: Vec<String>,
setup_code: &str,
master_password: Zeroizing<String>,
) -> Result<Zeroizing<String>, String> {
if self.core.has_account() {
return Err("This computer already has a vault.".into());
}
if setup_code.trim().is_empty() {
return Err("Enter the server's setup code.".into());
}
self.configure(server_url, pins)?;
let info = self.core.server_info().await.map_err(|e| describe(&e))?;
if !info.setup_open {
return Err(
"This server already has an account. Choose “Use my phone” or “Paste setup code” instead.".into()
);
}
let sk = Zeroizing::new(self.core.create_account(master_password.to_string()).map_err(|e| describe(&e))?);
self.remember_secret_key(&sk);
if let Err(e) = self.core.register_account(setup_code.trim().to_string()).await {
return Err(format!(
"Your vault was created on this computer, but the server refused it: {}. Check the setup code in \
Settings → Sync and try again.",
describe(&e)
));
}
let _ = self.sync_now().await;
Ok(sk)
}
/// For a vault created before sync was set up: registers it with a fresh server.
pub async fn register(&self, setup_code: &str) -> Result<(), String> {
self.core.register_account(setup_code.trim().to_string()).await.map_err(|e| describe(&e))?;
self.sync_now().await.map(|_| ())
}
/// Whether the configured server still accepts a setup code (no account yet).
pub async fn setup_open(&self) -> Result<bool, String> {
Ok(self.core.server_info().await.map_err(|e| describe(&e))?.setup_open)
}
// ------------------------------------------------------------ sync
/// Wakes the background loop (after unlock, after a save).
pub fn request_sync(&self) {
self.wake.notify_one();
}
fn can_sync(&self) -> bool {
self.key.is_ok() && self.core.has_account() && !self.core.is_locked() && lock(&self.status).server_url.is_some()
}
/// Runs forever: syncs when woken and every 15 minutes while unlocked.
pub async fn run(self: Arc<Self>) {
loop {
let _ = tokio::time::timeout(SYNC_PERIOD, self.wake.notified()).await;
tokio::time::sleep(COALESCE).await;
if self.can_sync() {
let _ = self.sync_now().await;
}
}
}
pub async fn sync_now(&self) -> Result<SyncStatus, String> {
self.require_key()?;
if self.core.is_locked() {
return Err("Unlock Kestrove first.".into());
}
self.update(|s| s.syncing = true);
let result = self.core.sync_now().await;
let mut pending_devices = None;
if result.is_ok() {
pending_devices = self
.core
.list_devices()
.await
.ok()
.map(|ds| ds.iter().filter(|d| !d.approved && !d.revoked && !d.current).count() as u32);
}
let err = result.as_ref().err().map(describe);
self.update(|s| {
s.syncing = false;
match &result {
Ok(r) => {
s.last_sync_at = Some(now_secs());
s.last_error = None;
s.pending_approval = false;
s.integrity_alarm = None;
s.restored_items = r.restored_item_ids.len() as u32;
if r.pulled + r.merged > 0 || !r.restored_item_ids.is_empty() {
s.changes += 1;
}
}
Err(CoreError::PendingApproval) => {
s.pending_approval = true;
s.last_error = None;
}
Err(e @ CoreError::IntegrityAlarm { .. }) => {
s.integrity_alarm = Some(describe(e));
s.last_error = err.clone();
// Other vaults may still have synced.
s.changes += 1;
}
Err(_) => s.last_error = err.clone(),
}
if let Some(n) = pending_devices {
s.pending_devices = n;
}
});
match err {
Some(e) if !matches!(result, Err(CoreError::PendingApproval)) => Err(e),
_ => Ok(self.status()),
}
}
// ------------------------------------------------------------ devices
pub async fn devices(&self) -> Result<Vec<DeviceView>, String> {
let ds = self.core.list_devices().await.map_err(|e| describe(&e))?;
let pending = ds.iter().filter(|d| !d.approved && !d.revoked && !d.current).count() as u32;
self.update(|s| s.pending_devices = pending);
Ok(ds.into_iter().map(device_view).collect())
}
/// After the user compared `key_fingerprint` with the one the other device shows.
pub async fn approve_device(&self, device_id: &str) -> Result<(), String> {
self.core.approve_device(device_id.to_string()).await.map_err(|e| describe(&e))?;
self.devices().await.map(|_| ())
}
pub async fn revoke_device(&self, device_id: &str) -> Result<(), String> {
self.core.revoke_device(device_id.to_string()).await.map_err(|e| describe(&e))?;
self.devices().await.map(|_| ())
}
// ------------------------------------------------------------ onboarding: a new computer
/// "Use my phone": opens a rendezvous and returns the QR. Then poll `join_poll` every 2 seconds.
pub async fn join_start(&self, server_url: &str) -> Result<JoinView, String> {
self.require_key()?;
let url = server_url.trim().trim_end_matches('/').to_string();
let session = self.core.join_start(url.clone()).await.map_err(|e| describe(&e))?;
self.update(|s| {
s.server_url = Some(url);
s.pins = vec![];
});
Ok(JoinView { qr_svg: qr::svg_data_uri(&session.qr)?, qr_text: session.qr, expires_at: session.expires_at })
}
pub async fn join_poll(&self) -> Result<JoinPollView, String> {
match self.core.join_poll().await {
Ok(p) => {
let setup = p.setup.map(|s| {
let view = setup_view(&s);
*lock(&self.pending_setup) = Some(s);
view
});
Ok(JoinPollView { code: p.code, setup })
}
Err(CoreError::NotFound) => Err("The code expired. Start again.".into()),
// The server allows one poll per second per rendezvous; a 429 just means "not yet".
Err(CoreError::Server { status: 429, .. }) => Ok(JoinPollView { code: None, setup: None }),
Err(e) => Err(describe(&e)),
}
}
pub fn join_cancel(&self) {
self.core.join_cancel();
}
/// "Paste setup code".
pub fn setup_from_text(&self, text: &str) -> Result<SetupView, String> {
let info = kv_ffi::parse_setup_payload(text.trim().to_string()).map_err(|e| describe(&e))?;
let view = setup_view(&info);
*lock(&self.pending_setup) = Some(info);
Ok(view)
}
/// "Open Emergency Kit…": a PNG/JPEG photo or the Kit PDF.
pub fn setup_from_file(&self, bytes: &[u8]) -> Result<SetupView, String> {
let texts = qr::decode_file(bytes)?;
let text = texts
.iter()
.find(|t| t.starts_with("kestrove://setup/"))
.ok_or("No Kestrove setup code was found in this file. Try a sharper photo, or paste the code.")?;
self.setup_from_text(text)
}
pub fn clear_setup(&self) {
*lock(&self.pending_setup) = None;
}
/// Signs in with the pending setup payload and the master password, then downloads the vault.
/// `PendingApproval` (a Kit code has no ticket): approve this computer elsewhere, then call again.
pub async fn finish_setup(&self, master_password: Zeroizing<String>) -> Result<SignIn, String> {
self.require_key()?;
let setup = lock(&self.pending_setup).clone().ok_or("Scan or paste a setup code first.")?;
let result =
self.core.login_with_setup(setup.clone(), master_password.to_string()).await.map_err(|e| describe(&e))?;
self.update(|s| {
s.server_url = Some(setup.server_url.clone());
s.pins = setup.spki_pins.clone();
s.pending_approval = result == LoginResult::PendingApproval;
});
if result == LoginResult::PendingApproval {
return Ok(SignIn::PendingApproval);
}
self.remember_secret_key(&setup.secret_key);
self.clear_setup();
let _ = self.sync_now().await;
Ok(SignIn::Ok)
}
// ------------------------------------------------------------ onboarding: from this (signed-in) computer
/// "Add a device": a 10-minute, single-use QR. Cancel (or lock) revokes it.
pub async fn add_device(&self) -> Result<AddDeviceView, String> {
self.cancel_add_device().await;
let p = self.core.add_device_payload().await.map_err(|e| describe(&e))?;
*lock(&self.add_device_ticket) = Some(p.ticket_id);
Ok(AddDeviceView { qr_svg: qr::svg_data_uri(&p.payload)?, expires_at: p.expires_at })
}
/// Revokes the "Add a device" ticket if one is on screen. A ticket already used or expired is fine.
pub async fn cancel_add_device(&self) {
let id = lock(&self.add_device_ticket).take();
if let Some(id) = id {
if let Err(e) = self.core.revoke_enrollment_ticket(id).await {
if !matches!(e, CoreError::NotFound) {
eprintln!("kestrove: could not revoke the enrollment ticket: {e}");
}
}
}
}
/// "Approve a computer": the join code shown (as QR and text) on the new computer.
pub async fn approve_join(&self, join_code: &str) -> Result<JoinConfirmView, String> {
let c = self.core.join_approve(join_code.trim().to_string()).await.map_err(|e| match e {
CoreError::NotFound => "That join code expired or was already used.".to_string(),
e => describe(&e),
})?;
Ok(JoinConfirmView {
code: c.code,
device_name: c.device_name,
platform: platform_name(c.platform).into(),
key_fingerprint: c.key_fingerprint,
})
}
/// After the user saw the same code on both screens.
pub async fn send_join(&self) -> Result<(), String> {
self.core.join_send().await.map_err(|e| describe(&e))
}
/// Emergency Kit setup code (server, pins, account, Secret Key; no ticket) as a QR.
pub fn kit_qr(&self) -> Result<String, String> {
qr::svg_data_uri(&self.core.setup_payload_for_kit().map_err(|e| describe(&e))?)
}
/// Called whenever the vault locks: nothing secret stays live (the core drops its keys itself).
pub async fn on_lock(&self) {
self.core.join_cancel();
self.cancel_add_device().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pins_split_on_commas_and_spaces() {
assert_eq!(parse_pins(" a=, b=\nc= "), vec!["a=", "b=", "c="]);
assert!(parse_pins(" ").is_empty());
}
#[test]
fn device_name_is_never_empty() {
assert!(!device_name().is_empty());
}
#[tokio::test]
async fn settings_persist_and_secrets_stay_out_of_views() {
let dir = tempfile::tempdir().unwrap();
let core = VaultCore::new_in_memory();
let key = DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string());
let svc = SyncService::new(core, dir.path(), key, Box::new(|_| {}));
svc.configure("http://127.0.0.1:9/", vec![]).unwrap();
assert_eq!(svc.status().server_url.as_deref(), Some("http://127.0.0.1:9"));
assert!(svc.status().device_fingerprint.is_some());
let payload = "kestrove://setup/v1?s=https%3A%2F%2Fkestrove.samialabri.com&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&k=K12TB9D5MPJTB9D5MPJTB9D5MPJTW";
let view = svc.setup_from_text(payload).unwrap();
assert_eq!(view.server_host, "kestrove.samialabri.com");
assert!(!view.preapproved);
assert!(!serde_json::to_string(&view).unwrap().contains("K12TB9"));
assert!(svc.setup_from_text("kestrove://setup/v9?s=x").is_err());
// A second service over the same folder sees the saved server.
let again = SyncService::new(
VaultCore::new_in_memory(),
dir.path(),
DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()),
Box::new(|_| {}),
);
assert_eq!(again.status().server_url.as_deref(), Some("http://127.0.0.1:9"));
assert_eq!(again.status().device_fingerprint, svc.status().device_fingerprint);
}
#[tokio::test]
async fn sync_needs_unlock() {
let dir = tempfile::tempdir().unwrap();
let svc = SyncService::new(
VaultCore::new_in_memory(),
dir.path(),
DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()),
Box::new(|_| {}),
);
assert!(svc.sync_now().await.is_err());
assert!(!svc.can_sync());
}
}