feat: encrypted profile sync through a shared folder

Profiles can be kept in step across machines through any folder they
share (cloud drive, network share, git checkout): one file,
nexus-profiles.nexus, in the encrypted-export format under a sync
password kept in the keychain/vault. Each sync merges profile by profile
(newest updated_at wins), propagates deletions as dated tombstones (an
edit after a deletion brings the profile back; tombstones expire after
180 days), validates every incoming profile, writes the file only when
it changed, and never syncs credentials.

Settings → Security → Profile sync picks the folder and sets the
password; syncs run at launch, 3 s after a profile change, every 10
minutes and on Sync Now (automatic runs stay quiet unless something
changed). An end-to-end test drives create, edit and delete between two
data directories sharing a folder.
This commit is contained in:
Andy Dixon
2026-10-12 19:20:12 +01:00
parent 32890c6415
commit 1c590f47ff
11 changed files with 437 additions and 15 deletions
+1
View File
@@ -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.
+9
View File
@@ -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.
+53 -1
View File
@@ -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<String>,
layout_checked: f64,
/// A profile sync in flight; when the next automatic one is due; whether it was asked for.
sync_job: Option<crate::settings::Job<Result<nexus_core::service::SyncReport, String>>>,
sync_due: f64,
sync_manual: bool,
rx: mpsc::Receiver<Event>,
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);
+64
View File
@@ -104,6 +104,7 @@ pub struct Settings {
known: Vec<KnownHost>,
/// 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<MasterPrompt>,
@@ -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) {
+2
View File
@@ -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<Protocol>,
+21 -14
View File
@@ -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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<Profile>> {
@@ -55,17 +72,7 @@ pub fn decrypt_and_import(path: &Path, password: &str) -> Result<Vec<Profile>> {
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)
}
+1
View File
@@ -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;
+3
View File
@@ -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(),
}
}
}
+80
View File
@@ -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<Store>,
@@ -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<String, Time> {
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<SyncReport> {
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<Profile> {
self.store.list_profiles().unwrap_or_default()
}
+152
View File
@@ -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<Profile>,
/// Deleted profile id → when.
#[serde(default)]
pub deleted: BTreeMap<String, Time>,
}
#[derive(Debug, Default, PartialEq)]
pub struct Merged {
/// Profiles to create or update locally.
pub upsert: Vec<Profile>,
/// Local profiles to delete.
pub delete: Vec<String>,
/// 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<String, Time>, 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<Option<SyncFile>> {
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::<Vec<_>>();
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::<Vec<_>>(), ["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();
}
}
+51
View File
@@ -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);
}