diff --git a/docs/overview.md b/docs/overview.md index ff138f1..8c03291 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -41,7 +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. -- Export and import profiles using encrypted `.nexus` bundles; older `.link` bundles from Alchementary Link import too. +- 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 654439c..f9fc15b 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -379,7 +379,7 @@ Connections, disconnections, profile changes, credential access, imports and exp ## 13. Import and export - **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 (or a `.link` file from Alchementary Link), enter its password, and choose what happens when a profile already exists: **Skip existing**, **Overwrite existing** or **Create duplicates**. +- **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. - **OpenSSH config**: **File → Import ~/.ssh/config** or **Settings → About → Import ~/.ssh/config** creates profiles from your `Host` blocks, including hostnames, usernames, ports, identity files and jump hosts. diff --git a/rust/app/src/settings.rs b/rust/app/src/settings.rs index 9a98f89..d32b801 100644 --- a/rust/app/src/settings.rs +++ b/rust/app/src/settings.rs @@ -864,7 +864,7 @@ When exporting profiles, encrypted export (a `.nexus` file) protects the file wi "## Exporting Profiles Use **Export (Encrypted)** from the sidebar menu or command palette. Choose which profiles to include and set a password to encrypt the file. Exported files use the `.nexus` extension. Plain **JSON** export is also available, optionally including secrets. ## Importing Profiles -Use **Import (Encrypted)**, select a `.nexus` file (older `.link` exports from Alchementary Link work too) and enter the password used when exporting. Choose whether existing profiles are skipped, overwritten or duplicated. Imported profiles appear in your sidebar immediately. +Use **Import (Encrypted)**, select a `.nexus` file and enter the password used when exporting. Choose whether existing profiles are skipped, overwritten or duplicated. Imported profiles appear in your sidebar immediately. ## Importing from OpenSSH If you have an existing `~/.ssh/config` file, go to **Settings → About** and click **Import ~/.ssh/config**. Nexus Terminal creates profiles from your SSH Host blocks, including hostnames, usernames, ports, identity files, and jump hosts.", ), diff --git a/rust/app/src/transfer.rs b/rust/app/src/transfer.rs index 12d89cd..7cf1fb7 100644 --- a/rust/app/src/transfer.rs +++ b/rust/app/src/transfer.rs @@ -1,4 +1,4 @@ -//! Profile export/import: encrypted `.nexus` (also reads `.link`) and plain JSON. +//! Profile export/import: encrypted `.nexus` and plain JSON. use crate::settings::{Job, poll}; use crate::state::{Request, Shared}; @@ -54,7 +54,7 @@ impl Transfer { pub fn import_encrypted(&mut self) { if let Some(path) = - rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION, "link"]).add_filter("All files", &["*"]).pick_file() + rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION]).add_filter("All files", &["*"]).pick_file() { self.mode = Some(Mode::Import { path, pw: String::new(), strategy: "skip", status: String::new() }); } diff --git a/rust/core/src/exportimport.rs b/rust/core/src/exportimport.rs index 14f79b1..8adbfd8 100644 --- a/rust/core/src/exportimport.rs +++ b/rust/core/src/exportimport.rs @@ -1,6 +1,5 @@ //! Password-encrypted profile exports: `[version 1][salt 32][nonce 12][AES-256-GCM ciphertext]` -//! over a JSON bundle, with the vault's Argon2id parameters. `.link` files from Alchementary -//! Link are the same format. +//! over a JSON bundle, with the vault's Argon2id parameters. use crate::Result; use crate::models::Profile; @@ -76,14 +75,13 @@ mod tests { use super::*; #[test] - fn reads_go_export_and_round_trips() { - let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/profiles.link"); - assert!(decrypt_and_import(&fx, "nope").is_err()); - let ps = decrypt_and_import(&fx, "export-pass").unwrap(); - assert_eq!(ps.len(), 6); - assert_eq!(ps[0].ssh.as_ref().unwrap().jump_hosts[0].port, 2222); + fn round_trips() { + let mut p = Profile::new(crate::models::Protocol::Ssh); + p.host = "box".into(); + let ps = vec![p]; let out = std::env::temp_dir().join(format!("nexus-export-{}.nexus", std::process::id())); encrypt_and_export(&ps, "pw", &out).unwrap(); + assert!(decrypt_and_import(&out, "nope").is_err()); assert_eq!(decrypt_and_import(&out, "pw").unwrap(), ps); std::fs::remove_file(out).unwrap(); } diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs index f4563bc..daf7960 100644 --- a/rust/core/src/models.rs +++ b/rust/core/src/models.rs @@ -1,5 +1,5 @@ -//! Data model shared by storage, the session manager and the UI. Field names serialise exactly as -//! the Go structs did, so database JSON columns and `.link`/`.nexus` exports round-trip unchanged. +//! Data model shared by storage, the session manager and the UI. Field names are the JSON keys +//! used in the database's config columns and in `.nexus` exports. use chrono::{DateTime, FixedOffset, Local}; use serde::{Deserialize, Deserializer, Serialize}; diff --git a/rust/core/src/recording.rs b/rust/core/src/recording.rs index 9c3fe03..bfbdb20 100644 --- a/rust/core/src/recording.rs +++ b/rust/core/src/recording.rs @@ -380,21 +380,6 @@ mod tests { d } - #[test] - fn reads_go_fixture() { - let store = Store::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/recordings")); - let (hdr, events) = store.get("rec1").unwrap(); - assert_eq!((hdr.version, hdr.width, hdr.height, hdr.timestamp), (2, 80, 24, 1791633486)); - assert_eq!(hdr.title, "Fixture — host"); - assert_eq!(hdr.env["TERM"], "xterm-256color"); - assert_eq!(events.len(), 2); - assert_eq!((events[0].kind.as_str(), events[0].data.as_str()), ("o", "hello \x1b[1mworld\x1b[0m\r\n")); - assert_eq!((events[1].kind.as_str(), events[1].data.as_str()), ("i", "ls\r")); - let metas = store.list().unwrap(); - assert_eq!(metas.len(), 1); - assert_eq!(metas[0].duration, 0.000212149); - } - #[test] fn write_and_close() { let dir = tmp("write"); diff --git a/rust/core/src/service.rs b/rust/core/src/service.rs index 9df2ca1..42214d1 100644 --- a/rust/core/src/service.rs +++ b/rust/core/src/service.rs @@ -274,7 +274,7 @@ impl Service { Ok(path) } - /// Imports a `.nexus` (or legacy `.link`) export. + /// Imports a `.nexus` export. pub fn import_encrypted(&self, path: &Path, password: &str, strategy: &str) -> Result { let ps = exportimport::decrypt_and_import(path, password)?; self.import(ps, strategy) diff --git a/rust/core/src/storage.rs b/rust/core/src/storage.rs index 2f0f2ea..5c7afd0 100644 --- a/rust/core/src/storage.rs +++ b/rust/core/src/storage.rs @@ -464,43 +464,69 @@ mod tests { } #[test] - fn opens_go_database_and_migrates() { - let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/link.db"); + fn profiles_and_app_state_round_trip() { let tmp = std::env::temp_dir().join(format!("nexus-store-{}.db", std::process::id())); - std::fs::copy(fx, &tmp).unwrap(); let s = Store::open(&tmp).unwrap(); - let ps = s.list_profiles().unwrap(); - assert_eq!(ps.len(), 6); - let ssh = ps.iter().find(|p| p.protocol == Protocol::Ssh).unwrap(); - assert!(ssh.favourite); - assert_eq!(ssh.tags, ["fixture", "ssh"]); - assert_eq!(ssh.last_connected.map(|t| fmt_time(&t)).as_deref(), Some("2026-01-02T03:04:05Z")); - let c = ssh.ssh.as_ref().unwrap(); - assert_eq!(c.env_vars["LANG"], "en_GB.UTF-8"); - assert_eq!(c.login_scripts.len(), 2); - assert_eq!(c.port_forwards[0].bind_port, 8080); - let irc = ps.iter().find(|p| p.protocol == Protocol::Irc).unwrap(); - assert_eq!(s.irc_last_rooms(&irc.id).unwrap(), ["#rust", "#nexus"]); - assert_eq!(s.list_known_hosts().unwrap()[0].fingerprint, "SHA256:abc"); - // Known hosts written by the Go app can be removed by id; unknown ids are an error. + let mut ssh = Profile::new(Protocol::Ssh); + ssh.host = "box".into(); + ssh.username = "me".into(); + ssh.favourite = true; + ssh.tags = vec!["a".into(), "b".into()]; + let c = ssh.ssh.as_mut().unwrap(); + c.env_vars.insert("LANG".into(), "en_GB.UTF-8".into()); + c.login_scripts = vec![LoginScriptStep { kind: "wait".into(), value: "$".into(), timeout: 5 }]; + c.port_forwards = vec![PortForward { id: "f1".into(), bind_port: 8080, dest_host: "db".into(), dest_port: 5432, enabled: true, ..Default::default() }]; + ssh.normalize(); + s.create_profile(&ssh).unwrap(); + let ssh = ssh.with_secs(); + assert_eq!(s.get_profile(&ssh.id).unwrap(), ssh); + s.update_profile(&ssh).unwrap(); + assert_eq!(s.get_profile(&ssh.id).unwrap(), ssh, "updates are lossless"); + let mut serial = Profile::new(Protocol::Serial); + serial.host = "/dev/ttyUSB0".into(); + serial.normalize(); + s.create_profile(&serial).unwrap(); + assert_eq!(s.list_profiles().unwrap().len(), 2); + + s.save_irc_last_rooms(&ssh.id, &["#rust".into(), "#nexus".into()]).unwrap(); + assert_eq!(s.irc_last_rooms(&ssh.id).unwrap(), ["#rust", "#nexus"]); + let t = parse_time("2026-01-02T03:04:05Z"); + let kh = KnownHost { + id: "k1".into(), + host: "box".into(), + port: 22, + key_type: "ssh-ed25519".into(), + fingerprint: "SHA256:abc".into(), + public_key: String::new(), + first_seen: t, + last_seen: t, + trusted: true, + }; + s.save_known_host(&kh).unwrap(); let kh = s.list_known_hosts().unwrap().remove(0); + assert_eq!(kh.fingerprint, "SHA256:abc"); s.delete_known_host(&kh.id).unwrap(); assert!(s.list_known_hosts().unwrap().is_empty()); - assert!(s.delete_known_host(&kh.id).is_err()); - s.save_known_host(&kh).unwrap(); + assert!(s.delete_known_host(&kh.id).is_err(), "unknown ids are an error"); + s.create_snippet(&Snippet { + id: "s1".into(), + name: "up".into(), + command: "uptime".into(), + description: String::new(), + folder: String::new(), + tags: vec![], + created_at: t, + updated_at: t, + }) + .unwrap(); assert_eq!(s.list_snippets().unwrap()[0].command, "uptime"); + let prefs = AppPreferences { terminal_theme: "dracula".into(), ..Default::default() }; + s.save_preferences(&prefs).unwrap(); assert_eq!(s.preferences().terminal_theme, "dracula"); - // Migration 007 lets serial profiles in. - let mut p = Profile::new(Protocol::Serial); - p.host = "/dev/ttyUSB0".into(); - p.normalize(); - s.create_profile(&p).unwrap(); - assert_eq!(s.get_profile(&p.id).unwrap(), p.clone().with_secs()); - // Round trip of an existing row is lossless. - s.update_profile(ssh).unwrap(); - assert_eq!(&s.get_profile(&ssh.id).unwrap(), ssh); drop(s); - let _ = std::fs::remove_file(&tmp); + for ext in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{ext}", tmp.display())); + } } impl Profile { diff --git a/rust/core/tests/fixtures/README.md b/rust/core/tests/fixtures/README.md deleted file mode 100644 index 19c1cc8..0000000 --- a/rust/core/tests/fixtures/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Compatibility fixtures - -Generated by the last Go build of Alchementary Link with `cmd/genfixtures` (see commit f588a79, -`git show f588a79:cmd/genfixtures/main.go`). Nexus Terminal must keep reading them unchanged: - -- `link.db` — a database through migration 006 with every protocol, a known host, a snippet, - IRC channel state and preferences. -- `profiles.link` — an encrypted export with password `export-pass`. -- `recordings/rec1.cast` — an asciicast v2 recording. - -Don't regenerate them; they are the reference for data written by the old app. diff --git a/rust/core/tests/fixtures/link.db b/rust/core/tests/fixtures/link.db deleted file mode 100644 index ecc833d..0000000 Binary files a/rust/core/tests/fixtures/link.db and /dev/null differ diff --git a/rust/core/tests/fixtures/profiles.link b/rust/core/tests/fixtures/profiles.link deleted file mode 100644 index cf045f9..0000000 Binary files a/rust/core/tests/fixtures/profiles.link and /dev/null differ diff --git a/rust/core/tests/fixtures/recordings/rec1.cast b/rust/core/tests/fixtures/recordings/rec1.cast deleted file mode 100644 index 78a78ee..0000000 --- a/rust/core/tests/fixtures/recordings/rec1.cast +++ /dev/null @@ -1,3 +0,0 @@ -{"version":2,"width":80,"height":24,"timestamp":1791633486,"title":"Fixture — host","env":{"TERM":"xterm-256color"}} -[0.000139144,"o","hello \u001b[1mworld\u001b[0m\r\n"] -[0.000212149,"i","ls\r"]