diff --git a/docs/overview.md b/docs/overview.md index 97fe7b5..4a04528 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -41,6 +41,7 @@ Use Nexus Terminal when you need to: - Multi-select profiles with Ctrl/Cmd-click for bulk connect, disconnect, tagging, moving between groups and deleting. - Test basic host and port reachability before connecting. - Import existing OpenSSH config entries from `~/.ssh/config`, including hostnames, usernames, ports, identity files and jump hosts. +- Sync profiles between machines through any shared folder, as one encrypted file (newest change wins; deletions propagate; credentials stay local). - Export and import profiles using encrypted `.nexus` bundles. - Plain JSON export and import, with secrets redacted unless you choose to include them. - Choose import behaviour for duplicates: skip, overwrite or duplicate. diff --git a/docs/user-manual.md b/docs/user-manual.md index fde11db..33e931b 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -428,6 +428,15 @@ Connections, disconnections, profile changes, credential access, imports and exp ## 13. Import and export +### Profile sync + +**Settings → Security → Profile sync** keeps connection profiles the same on several machines. Choose a folder all of them can reach (a Dropbox, iCloud Drive, OneDrive or Nextcloud folder, a network share, or a git checkout you commit), and set the same **sync password** on each. Nexus Terminal keeps one encrypted file there, `nexus-profiles.nexus` (Argon2id + AES-256-GCM, like an encrypted export), and merges it at launch, a few seconds after you change a profile, every ten minutes and when you click **Sync Now**: + +- for a profile changed on both sides, the more recent change wins; +- a profile deleted on one machine is deleted on the others (unless it was edited after the deletion); +- passwords, keys and other credentials are never synced; each machine keeps its own in its keychain or vault. + + - **Encrypted export**: **File → Export profiles…**, the sidebar footer, the command palette, or **Export…** on a profile's right-click menu. Choose the profiles, set a password and save a `.nexus` file. Credentials saved in the keychain or vault are not included. - **Encrypted import**: **File → Import profiles…** or the sidebar footer. Choose a `.nexus` file, enter its password, and choose what happens when a profile already exists: **Skip existing**, **Overwrite existing** or **Create duplicates**. - **JSON export and import**: **Export Profiles (JSON)** and **Import Profiles (JSON)** in the command palette. JSON export includes every profile; passwords in login-script `send` steps that answer a password prompt are blanked unless you tick **Include secrets**. Proxy passwords and TOTP secrets live in the keychain or vault, never in the profile, so no export carries them. diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index 91fe67b..6003da9 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -124,6 +124,10 @@ pub struct App { /// The tab layout as last saved for restoring (see restore.rs), and when it was last checked. pub saved_layout: Option, layout_checked: f64, + /// A profile sync in flight; when the next automatic one is due; whether it was asked for. + sync_job: Option>>, + sync_due: f64, + sync_manual: bool, rx: mpsc::Receiver, pub theme: ThemeKind, pub editor: profile_editor::ProfileEditor, @@ -200,6 +204,9 @@ impl App { resize_sidebar: false, saved_layout: None, layout_checked: 0.0, + sync_job: None, + sync_due: 0.0, + sync_manual: false, }; let restore = opts.connect.is_empty() && app.run_local.is_none() && opts.open.is_none() && app.s.prefs.restore_sessions; if restore { @@ -614,7 +621,15 @@ impl App { Err(e) => self.s.error(format!("Can't connect to {name}: {e}")), } } - Request::ReloadProfiles => self.s.reload_profiles(), + Request::ReloadProfiles => { + self.s.reload_profiles(); + // A profile changed: sync it out shortly (batched with any further edits). + self.sync_due = self.sync_due.min(self.s.now + 3.0); + } + Request::SyncProfiles => { + self.sync_manual = true; + self.sync_due = 0.0; + } Request::ReloadPreferences => { self.s.prefs = self.s.svc.preferences(); self.resize_sidebar = true; @@ -784,6 +799,42 @@ impl App { } } + /// Profile sync: at launch, a few seconds after a profile changes, every 10 minutes and on + /// request, when a folder and password are set. Automatic runs stay quiet unless something + /// changed or a manual one failed. + fn drive_sync(&mut self, ctx: &egui::Context) { + if let Some(r) = crate::settings::poll(&mut self.sync_job) { + let manual = std::mem::take(&mut self.sync_manual); + match r { + Ok(rep) => { + if rep.pulled + rep.removed > 0 { + self.s.reload_profiles(); + self.s.info(format!("Profile sync: {} updated, {} removed", rep.pulled, rep.removed)); + } else if manual { + self.s.success(if rep.pushed { "Profiles synced" } else { "Profiles already in sync" }); + } + if rep.skipped > 0 { + self.s.error(format!("Profile sync skipped {} invalid profile(s) from the shared file", rep.skipped)); + } + } + Err(e) if manual => self.s.error(format!("Profile sync failed: {e}")), + Err(e) => tracing::warn!(error = %e, "profile sync failed"), + } + } + if self.screenshot.is_some() || self.sync_job.is_some() || self.s.now < self.sync_due { + return; + } + self.sync_due = self.s.now + 600.0; + if self.s.prefs.sync_folder.trim().is_empty() { + if std::mem::take(&mut self.sync_manual) { + self.s.error("Choose a sync folder first"); + } + return; + } + let svc = self.s.svc.clone(); + self.sync_job = Some(crate::settings::Job::spawn(ctx, move || svc.sync_profiles().map_err(|e| e.to_string()))); + } + pub fn toggle_broadcast(&mut self) { self.s.broadcast = !self.s.broadcast; if !self.s.broadcast { @@ -1012,6 +1063,7 @@ impl eframe::App for App { chrome::palette(self, &ctx); chrome::toasts(self, &ctx); self.screenshot(&ctx); + self.drive_sync(&ctx); if self.screenshot.is_none() && self.s.prefs.restore_sessions && self.s.now - self.layout_checked > 1.0 { self.layout_checked = self.s.now; crate::restore::save_if_changed(self); diff --git a/rust/app/src/settings.rs b/rust/app/src/settings.rs index 2034b32..9fc08e9 100644 --- a/rust/app/src/settings.rs +++ b/rust/app/src/settings.rs @@ -104,6 +104,7 @@ pub struct Settings { known: Vec, /// Add-CA form: (host pattern, public key), shown when Some. new_ca: Option<(String, String)>, + sync_password: String, default_shell: String, help_topic: usize, master: Option, @@ -381,6 +382,69 @@ impl Settings { s.info("Secret store locked"); } }); + self.sync_section(ui, s); + } + + fn sync_section(&mut self, ui: &mut Ui, s: &mut Shared) { + ui.add_space(12.0); + widgets::section(ui, "Profile sync"); + widgets::hint( + ui, + "Keep connection profiles in step across machines through a folder they share (a cloud drive, a network share, a git checkout). \ + Profiles are stored there encrypted with the sync password; passwords and keys are never synced.", + ); + form(ui, "sync-form", |ui| { + field(ui, "Folder"); + ui.horizontal(|ui| { + let shown = if self.prefs.sync_folder.is_empty() { "Not set".to_string() } else { self.prefs.sync_folder.clone() }; + ui.label(RichText::new(shown).font(theme::mono(12.0))); + if widgets::button(ui, icon::FOLDER_OPEN, "Choose…").clicked() + && let Some(dir) = rfd::FileDialog::new().set_title("Folder to sync profiles through").pick_folder() + { + self.set_sync_folder(s, dir.to_string_lossy().into_owned()); + } + if !self.prefs.sync_folder.is_empty() && ui.button("Turn Off").clicked() { + self.set_sync_folder(s, String::new()); + } + }); + ui.end_row(); + field(ui, "Sync password"); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.sync_password) + .password(true) + .hint_text(if s.svc.has_sync_password() { "Stored — type to replace" } else { "Same on every machine" }) + .desired_width(220.0), + ); + if ui.add_enabled(!self.sync_password.is_empty(), egui::Button::new("Save")).clicked() { + match s.svc.set_sync_password(&self.sync_password) { + Ok(()) => s.success("Sync password stored"), + Err(e) => s.error(format!("Couldn't store the sync password: {e}")), + } + self.sync_password.clear(); + } + }); + ui.end_row(); + field(ui, ""); + if widgets::button(ui, icon::ARROWS_CLOCKWISE, "Sync Now").clicked() { + s.request(Request::SyncProfiles); + } + ui.end_row(); + }); + } + + /// Saves just the sync folder (the rest of the form keeps its unsaved edits). + fn set_sync_folder(&mut self, s: &mut Shared, folder: String) { + let mut stored = s.svc.preferences(); + stored.sync_folder = folder.clone(); + match s.svc.save_preferences(&stored) { + Ok(()) => { + self.prefs.sync_folder = folder.clone(); + s.prefs.sync_folder = folder; + s.request(Request::SyncProfiles); + } + Err(e) => s.error(format!("Failed to save: {e}")), + } } fn master_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) { diff --git a/rust/app/src/state.rs b/rust/app/src/state.rs index ca153e7..334b053 100644 --- a/rust/app/src/state.rs +++ b/rust/app/src/state.rs @@ -10,6 +10,8 @@ use std::sync::Arc; /// Things a feature asks the main window to do (handled once per frame, in order). #[allow(clippy::large_enum_variant)] // a per-frame queue; boxing buys nothing pub enum Request { + /// Merge profiles with the sync folder (manual, or after a change). + SyncProfiles, /// Open the profile editor for a new profile (optionally pre-set protocol / folder). NewProfile { protocol: Option, diff --git a/rust/core/src/exportimport.rs b/rust/core/src/exportimport.rs index 8adbfd8..88d1a94 100644 --- a/rust/core/src/exportimport.rs +++ b/rust/core/src/exportimport.rs @@ -36,16 +36,33 @@ pub fn encrypt_and_export(profiles: &[Profile], password: &str, path: &Path) -> profiles: profiles.to_vec(), count: profiles.len(), }; - let plain = serde_json::to_vec_pretty(&bundle)?; + crate::secrets::write_private(path, &seal(&serde_json::to_vec_pretty(&bundle)?, password)?) +} + +/// `[version][salt 32][nonce 12][AES-256-GCM ciphertext]` under an Argon2id key from `password`. +pub fn seal(plain: &[u8], password: &str) -> Result> { let salt = random_bytes::<32>(); let nonce = random_bytes::<12>(); let key = derive_key(password, &salt)?; - let ct = Aes256Gcm::new((&*key).into()).encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|e| e.to_string())?; + let ct = Aes256Gcm::new((&*key).into()).encrypt(Nonce::from_slice(&nonce), plain).map_err(|e| e.to_string())?; let mut out = vec![VERSION]; out.extend(salt); out.extend(nonce); out.extend(ct); - crate::secrets::write_private(path, &out) + Ok(out) +} + +pub fn open(data: &[u8], password: &str) -> Result> { + if data.len() < 1 + 32 + 12 + 1 + 16 { + return Err("exportimport: file is too small or corrupt".into()); + } + if data[0] != VERSION { + return Err(format!("exportimport: unsupported format version {}", data[0]).into()); + } + let key = derive_key(password, &data[1..33])?; + Ok(Aes256Gcm::new((&*key).into()) + .decrypt(Nonce::from_slice(&data[33..45]), &data[45..]) + .map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?) } pub fn decrypt_and_import(path: &Path, password: &str) -> Result> { @@ -55,17 +72,7 @@ pub fn decrypt_and_import(path: &Path, password: &str) -> Result> { if std::fs::metadata(path)?.len() > MAX_IMPORT_BYTES { return Err("exportimport: file is too large (max 10 MiB)".into()); } - let data = std::fs::read(path)?; - if data.len() < 1 + 32 + 12 + 1 + 16 { - return Err("exportimport: file is too small or corrupt".into()); - } - if data[0] != VERSION { - return Err(format!("exportimport: unsupported format version {}", data[0]).into()); - } - let key = derive_key(password, &data[1..33])?; - let plain = Aes256Gcm::new((&*key).into()) - .decrypt(Nonce::from_slice(&data[33..45]), &data[45..]) - .map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?; + let plain = open(&std::fs::read(path)?, password)?; let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?; Ok(bundle.profiles) } diff --git a/rust/core/src/lib.rs b/rust/core/src/lib.rs index 41db584..9e088de 100644 --- a/rust/core/src/lib.rs +++ b/rust/core/src/lib.rs @@ -14,6 +14,7 @@ pub mod service; pub mod sessions; pub mod sshconfig; pub mod storage; +pub mod sync; pub mod terminalenv; pub mod updater; pub mod validation; diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs index 2d0924b..7ab1ac6 100644 --- a/rust/core/src/models.rs +++ b/rust/core/src/models.rs @@ -628,6 +628,8 @@ pub struct AppPreferences { pub confirm_multiline_paste: bool, /// Reopen the previous session's tabs and splits at launch. pub restore_sessions: bool, + /// Folder holding the encrypted profile sync file (empty: sync off). + pub sync_folder: String, } impl Default for AppPreferences { @@ -650,6 +652,7 @@ impl Default for AppPreferences { allow_remote_clipboard: true, confirm_multiline_paste: true, restore_sessions: true, + sync_folder: String::new(), } } } diff --git a/rust/core/src/service.rs b/rust/core/src/service.rs index 48e3914..e871e37 100644 --- a/rust/core/src/service.rs +++ b/rust/core/src/service.rs @@ -21,6 +21,23 @@ pub const VERSION: &str = match option_env!("NEXUS_VERSION") { }; const MAX_IMPORT_JSON_BYTES: usize = 5 * 1024 * 1024; +/// Secret-store key of the profile-sync password. +const SYNC_PASSWORD: &str = "sync:password"; +/// Setting holding deleted profile ids and when, for sync. +const SYNC_DELETED: &str = "sync_deleted"; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct SyncReport { + /// Profiles created or updated from the shared file. + pub pulled: usize, + /// Profiles deleted because another machine deleted them. + pub removed: usize, + /// Incoming profiles that failed validation. + pub skipped: usize, + /// Whether the shared file was rewritten. + pub pushed: bool, +} + pub struct Service { pub config: AppConfig, pub store: Arc, @@ -72,6 +89,10 @@ impl Service { let p = self.store.get_profile(id).map_err(|e| format!("profile not found: {e}"))?; self.store.delete_profile(id).map_err(|e| format!("failed to delete profile: {e}"))?; self.secrets.delete_profile(id); + // So other machines syncing the same folder delete it too. + let mut gone = self.sync_tombstones(); + gone.insert(id.to_string(), now()); + let _ = self.store.set_setting(SYNC_DELETED, &serde_json::to_string(&gone).unwrap_or_default()); self.store.add_audit("profile.delete", id, "", &format!("Deleted profile {:?}", p.display_name)); Ok(()) } @@ -88,6 +109,65 @@ impl Service { Ok(d) } + fn sync_tombstones(&self) -> std::collections::BTreeMap { + self.store.setting(SYNC_DELETED).and_then(|j| serde_json::from_str(&j).ok()).unwrap_or_default() + } + + /// Sets (or, when empty, forgets) the profile-sync password. + pub fn set_sync_password(&self, password: &str) -> Result<()> { + if password.is_empty() { + self.secrets.delete(SYNC_PASSWORD); + return Ok(()); + } + self.secrets.set_checked(SYNC_PASSWORD, password) + } + + pub fn has_sync_password(&self) -> bool { + self.secrets.get(SYNC_PASSWORD).is_some() + } + + /// Merges profiles with the shared sync file; see [`crate::sync`]. + pub fn sync_profiles(&self) -> Result { + let folder = self.store.preferences().sync_folder; + if folder.trim().is_empty() { + return Err("profile sync isn't set up (choose a folder in Settings)".into()); + } + let folder = PathBuf::from(folder.trim()); + if !folder.is_dir() { + return Err(format!("sync folder {} isn't available", folder.display()).into()); + } + let password = self.secrets.get(SYNC_PASSWORD).ok_or("no sync password is stored (set one in Settings)")?; + let remote = crate::sync::read(&folder, &password)?.unwrap_or_default(); + let m = crate::sync::merge(&self.profiles(), &self.sync_tombstones(), &remote, now()); + let mut report = SyncReport::default(); + for mut p in m.upsert { + // The file is ours, but it crossed a shared folder: validate what comes in. + p.normalize(); + if validation::validate_profile(&p).is_err() { + report.skipped += 1; + continue; + } + let r = if self.store.get_profile(&p.id).is_ok() { self.store.update_profile(&p) } else { self.store.create_profile(&p) }; + match r { + Ok(()) => report.pulled += 1, + Err(_) => report.skipped += 1, + } + } + for id in &m.delete { + if self.store.delete_profile(id).is_ok() { + self.secrets.delete_profile(id); + report.removed += 1; + } + } + if m.file != remote { + crate::sync::write(&folder, &password, &m.file)?; + report.pushed = true; + } + self.store.set_setting(SYNC_DELETED, &serde_json::to_string(&m.file.deleted)?)?; + self.store.add_audit("sync", "", "", &format!("Profile sync: {} in, {} removed, wrote file: {}", report.pulled, report.removed, report.pushed)); + Ok(report) + } + pub fn profiles(&self) -> Vec { self.store.list_profiles().unwrap_or_default() } diff --git a/rust/core/src/sync.rs b/rust/core/src/sync.rs new file mode 100644 index 0000000..caec54d --- /dev/null +++ b/rust/core/src/sync.rs @@ -0,0 +1,152 @@ +//! Profile sync through a folder the user picks (a cloud drive, a network share, a git checkout): +//! one encrypted file, `nexus-profiles.nexus`, in the export format (Argon2id + AES-256-GCM) under +//! a sync password. Each side merges profile by profile — the newer `updated_at` wins — and +//! deletions travel as dated tombstones, so a profile removed on one machine goes away on the +//! others. Credentials are never synced: they live in each machine's keychain or vault. + +use crate::Result; +use crate::models::{Profile, Time}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; + +pub const FILE_NAME: &str = "nexus-profiles.nexus"; +/// Tombstones older than this are forgotten (every machine has had time to see them). +const TOMBSTONE_DAYS: i64 = 180; +const MAX_FILE: u64 = 20 << 20; + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SyncFile { + pub version: i64, + pub profiles: Vec, + /// Deleted profile id → when. + #[serde(default)] + pub deleted: BTreeMap, +} + +#[derive(Debug, Default, PartialEq)] +pub struct Merged { + /// Profiles to create or update locally. + pub upsert: Vec, + /// Local profiles to delete. + pub delete: Vec, + /// What the shared file should now hold. + pub file: SyncFile, +} + +/// Merges local state with the shared file. +pub fn merge(local: &[Profile], local_deleted: &BTreeMap, remote: &SyncFile, now: Time) -> Merged { + let mut deleted = remote.deleted.clone(); + for (id, t) in local_deleted { + let e = deleted.entry(id.clone()).or_insert(*t); + *e = (*e).max(*t); + } + deleted.retain(|_, t| (now - *t).num_days() < TOMBSTONE_DAYS); + + let remote_by: HashMap<&str, &Profile> = remote.profiles.iter().map(|p| (p.id.as_str(), p)).collect(); + let local_by: HashMap<&str, &Profile> = local.iter().map(|p| (p.id.as_str(), p)).collect(); + let mut out = Merged::default(); + let mut ids: Vec<&str> = local_by.keys().chain(remote_by.keys()).copied().collect(); + ids.sort_unstable(); + ids.dedup(); + for id in ids { + let winner = match (local_by.get(id), remote_by.get(id)) { + (Some(l), Some(r)) => { + if r.updated_at > l.updated_at { + out.upsert.push((*r).clone()); + *r + } else { + *l + } + } + (Some(l), None) => *l, + (None, Some(r)) => *r, + (None, None) => continue, + }; + // A deletion newer than the last edit wins; an edit after the deletion brings it back. + if deleted.get(id).is_some_and(|t| *t >= winner.updated_at) { + if local_by.contains_key(id) { + out.delete.push(id.to_string()); + } + out.upsert.retain(|p| p.id != id); + continue; + } + deleted.remove(id); + if !local_by.contains_key(id) { + out.upsert.push(winner.clone()); + } + out.file.profiles.push(winner.clone()); + } + out.file.version = 1; + out.file.deleted = deleted; + out +} + +pub fn read(folder: &Path, password: &str) -> Result> { + let path = folder.join(FILE_NAME); + let Ok(meta) = std::fs::metadata(&path) else { return Ok(None) }; + if meta.len() > MAX_FILE { + return Err("sync file is too large".into()); + } + let plain = crate::exportimport::open(&std::fs::read(&path)?, password).map_err(|_| "the sync password doesn't open the sync file (or it is damaged)")?; + Ok(Some(serde_json::from_slice(&plain).map_err(|e| format!("sync file isn't readable: {e}"))?)) +} + +pub fn write(folder: &Path, password: &str, file: &SyncFile) -> Result<()> { + crate::secrets::write_private(&folder.join(FILE_NAME), &crate::exportimport::seal(&serde_json::to_vec(file)?, password)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Protocol; + + fn p(id: &str, host: &str, t: i64) -> Profile { + let mut p = Profile::new(Protocol::Ssh); + p.id = id.into(); + p.host = host.into(); + p.updated_at = chrono::DateTime::from_timestamp(t, 0).unwrap().fixed_offset(); + p + } + fn at(t: i64) -> Time { + chrono::DateTime::from_timestamp(t, 0).unwrap().fixed_offset() + } + + #[test] + fn newest_wins_and_deletions_travel() { + let now = at(1_000_000); + let local = vec![p("a", "a-local-new", 200), p("b", "b-local-old", 100), p("c", "only-local", 100), p("d", "deleted-remotely", 100)]; + let remote = SyncFile { + version: 1, + profiles: vec![p("a", "a-remote-old", 150), p("b", "b-remote-new", 300), p("e", "only-remote", 100), p("f", "deleted-locally", 100)], + deleted: [("d".to_string(), at(150))].into(), + }; + let local_deleted = [("f".to_string(), at(120))].into(); + let m = merge(&local, &local_deleted, &remote, now); + let hosts = |v: &[Profile]| v.iter().map(|p| p.host.clone()).collect::>(); + assert_eq!(hosts(&m.upsert), ["b-remote-new", "only-remote"]); + assert_eq!(m.delete, ["d"]); + assert_eq!(hosts(&m.file.profiles), ["a-local-new", "b-remote-new", "only-local", "only-remote"]); + assert_eq!(m.file.deleted.keys().collect::>(), ["d", "f"]); + // An edit after a deletion brings the profile back (and clears the tombstone). + let remote = SyncFile { version: 1, profiles: vec![], deleted: [("x".to_string(), at(100))].into() }; + let m = merge(&[p("x", "edited-later", 200)], &BTreeMap::new(), &remote, now); + assert!(m.delete.is_empty() && m.file.deleted.is_empty() && m.file.profiles.len() == 1); + // Old tombstones are forgotten. + let remote = SyncFile { version: 1, profiles: vec![], deleted: [("old".to_string(), at(0))].into() }; + assert!(merge(&[], &BTreeMap::new(), &remote, at(TOMBSTONE_DAYS * 86_400 + 10)).file.deleted.is_empty()); + } + + #[test] + fn file_round_trips_under_the_password() { + let dir = std::env::temp_dir().join(format!("nexus-sync-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = SyncFile { version: 1, profiles: vec![p("a", "h", 1)], deleted: BTreeMap::new() }; + write(&dir, "pw", &f).unwrap(); + assert_eq!(read(&dir, "pw").unwrap().unwrap().profiles[0].host, "h"); + assert!(read(&dir, "wrong").is_err()); + assert!(read(&dir.join("missing"), "pw").unwrap().is_none()); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/rust/core/tests/sync.rs b/rust/core/tests/sync.rs new file mode 100644 index 0000000..e2d03a5 --- /dev/null +++ b/rust/core/tests/sync.rs @@ -0,0 +1,51 @@ +//! Profile sync between two "machines" (data directories) sharing one folder. + +use nexus_core::config::AppConfig; +use nexus_core::models::{Profile, Protocol}; +use nexus_core::service::Service; + +fn machine(root: &std::path::Path, name: &str, share: &std::path::Path) -> Service { + let cfg = AppConfig::in_dir(root.join(name)); + std::fs::create_dir_all(&cfg.vault_dir).unwrap(); + let svc = Service::open(cfg).unwrap(); + svc.secrets.vault.initialise("master").unwrap(); + let mut prefs = svc.preferences(); + prefs.sync_folder = share.to_string_lossy().into_owned(); + svc.save_preferences(&prefs).unwrap(); + svc.set_sync_password("shared-secret").unwrap(); + svc +} + +#[test] +fn profiles_follow_edits_and_deletes_across_machines() { + let root = std::env::temp_dir().join(format!("nexus-sync-e2e-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let share = root.join("share"); + std::fs::create_dir_all(&share).unwrap(); + let (a, b) = (machine(&root, "a", &share), machine(&root, "b", &share)); + + let mut p = Profile::new(Protocol::Ssh); + p.display_name = "web".into(); + p.host = "web.example".into(); + p.username = "me".into(); + let p = a.create_profile(p).unwrap(); + assert!(a.sync_profiles().unwrap().pushed); + assert_eq!(b.sync_profiles().unwrap().pulled, 1); + assert_eq!(b.profiles()[0].host, "web.example"); + + std::thread::sleep(std::time::Duration::from_millis(1100)); // updated_at has 1 s resolution + let mut edited = b.profiles()[0].clone(); + edited.host = "web2.example".into(); + b.update_profile(edited).unwrap(); + b.sync_profiles().unwrap(); + assert_eq!(a.sync_profiles().unwrap().pulled, 1); + assert_eq!(a.profiles()[0].host, "web2.example"); + + a.delete_profile(&p.id).unwrap(); + a.sync_profiles().unwrap(); + assert_eq!(b.sync_profiles().unwrap().removed, 1); + assert!(b.profiles().is_empty()); + // Nothing left to do on either side. + assert!(!a.sync_profiles().unwrap().pushed && !b.sync_profiles().unwrap().pushed); + let _ = std::fs::remove_dir_all(root); +}