diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fd7e8ad..1dee1ec 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use serde::Serialize; @@ -132,10 +132,68 @@ pub fn import_mremoteng(state: State, path: String) -> Result/missing/../vault` +/// has neither `p` nor `p.parent()` existing, so naively it would resolve to nothing and slip +/// past the guard; yet the export's `create_dir_all` would materialize `missing/` and the rename +/// would land on `/vault`. Resolving the tail lexically collapses it back onto the vault. +/// `None` only when no ancestor resolves. Purely lexical on the non-existent tail is safe — those +/// components can hold no symlinks — because the existing prefix is already fully canonicalized. +fn resolve_for_compare(p: &Path) -> Option { + use std::path::Component; + if let Ok(c) = std::fs::canonicalize(p) { + return Some(c); + } + let mut ancestor = p; + let mut tail: Vec = Vec::new(); + loop { + tail.push(ancestor.components().next_back()?); + let parent = ancestor.parent().filter(|pp| !pp.as_os_str().is_empty())?; + if let Ok(base) = std::fs::canonicalize(parent) { + let mut out = base; + for comp in tail.iter().rev() { + match comp { + Component::Normal(n) => out.push(n), + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + _ => return None, + } + } + return Some(out); + } + ancestor = parent; + } +} + +/// Refuse to write an export over the live vault file. The OS save dialog lets the user aim +/// anywhere, including `~/.config/remota/connections.dat`; overwriting it with an export sealed +/// under a different password would lock them out of their vault on the next unlock. Refuses, +/// before any byte is written, on two kinds of match: (1) device+inode identity when the target +/// already exists — which also catches a hardlink to the vault, invisible to `canonicalize`; and +/// (2) resolved-path equality for a target that doesn't exist yet, with `.`/`..` normalized so a +/// spelling through a not-yet-created directory can't slip past. +fn guard_not_vault_path(vault_path: &Path, target: &Path) -> Result<(), VaultError> { + use std::os::unix::fs::MetadataExt; + if let (Ok(v), Ok(t)) = (std::fs::metadata(vault_path), std::fs::metadata(target)) { + if v.dev() == t.dev() && v.ino() == t.ino() { + return Err(VaultError::WouldOverwriteVault); + } + } + match (resolve_for_compare(vault_path), resolve_for_compare(target)) { + (Some(v), Some(t)) if v == t => Err(VaultError::WouldOverwriteVault), + _ => Ok(()), + } +} + /// Exporta a árvore de conexões para um JSON em claro (backup/portabilidade). /// AVISO: contém as passwords em claro — é uma exportação explícita. #[tauri::command] pub fn export_connections(state: State, path: String) -> Result { + guard_not_vault_path(state.vault.path(), Path::new(&path))?; let doc = state.vault.tree()?; let json = serde_json::to_vec_pretty(&doc).map_err(|e| VaultError::Io(e.to_string()))?; std::fs::write(&path, &json).map_err(|e| VaultError::Io(e.to_string()))?; @@ -189,6 +247,7 @@ pub fn export_connections_encrypted( path: String, password: String, ) -> Result { + guard_not_vault_path(state.vault.path(), Path::new(&path))?; let doc = state.vault.tree()?; write_encrypted_export(Path::new(&path), &password, &doc)?; let connections = crate::importer::count_connections(&doc.nodes); @@ -201,13 +260,17 @@ pub fn export_connections_encrypted( /// Decodifica um export do Remota — ficheiro encriptado (formato do cofre, precisa de /// `password`) OU JSON em claro. O byte de versão (não a extensão) decide qual: um export /// encriptado começa com `0x01`, o JSON em claro com `{`. -fn decode_remota_import(bytes: &[u8], password: Option<&str>) -> Result { +/// Returns the decoded document and whether the source was encrypted (so the caller can report +/// the right provenance). +fn decode_remota_import(bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), VaultError> { if is_encrypted(bytes) { let pw = password.ok_or(VaultError::NeedsPassword)?; let plain = open_document(bytes, pw)?; - serde_json::from_slice(&plain).map_err(|_| VaultError::BadFormat) + let doc = serde_json::from_slice(&plain).map_err(|_| VaultError::BadFormat)?; + Ok((doc, true)) } else { - serde_json::from_slice(bytes).map_err(|_| VaultError::BadFormat) + let doc = serde_json::from_slice(bytes).map_err(|_| VaultError::BadFormat)?; + Ok((doc, false)) } } @@ -221,11 +284,16 @@ pub fn import_remota_json( password: Option, ) -> Result { let bytes = std::fs::read(&path).map_err(|e| VaultError::Io(e.to_string()))?; - let doc = decode_remota_import(&bytes, password.as_deref())?; + let (doc, encrypted) = decode_remota_import(&bytes, password.as_deref())?; let connections = state.vault.import(doc.nodes)?; + let source = if encrypted { + "an encrypted Remota export" + } else { + "a Remota JSON export" + }; Ok(ImportReport { connections, - message: format!("{connections} connections imported from Remota JSON."), + message: format!("{connections} connections imported from {source}."), }) } @@ -408,26 +476,30 @@ mod tests { #[test] fn decode_plaintext_json_returns_doc() { - let doc = decode_remota_import(b"{\"nodes\":[],\"trash\":[]}", None).unwrap(); + let (doc, encrypted) = decode_remota_import(b"{\"nodes\":[],\"trash\":[]}", None).unwrap(); assert!(doc.nodes.is_empty()); + assert!(!encrypted, "plaintext JSON must report as not encrypted"); } #[test] fn decode_plaintext_ignores_password() { - let doc = decode_remota_import(b"{\"nodes\":[],\"trash\":[]}", Some("anything")).unwrap(); + let (doc, encrypted) = + decode_remota_import(b"{\"nodes\":[],\"trash\":[]}", Some("anything")).unwrap(); assert!(doc.nodes.is_empty()); + assert!(!encrypted); } #[test] fn decode_encrypted_with_password_returns_doc() { let original = doc_with_password("pw-secret"); let bytes = sealed_bytes("decode-ok", &original, "P"); - let got = decode_remota_import(&bytes, Some("P")).unwrap(); + let (got, encrypted) = decode_remota_import(&bytes, Some("P")).unwrap(); assert_eq!( serde_json::to_vec(&got).unwrap(), serde_json::to_vec(&original).unwrap(), "decrypts to the same tree" ); + assert!(encrypted, "a sealed file must report as encrypted"); } #[test] @@ -481,4 +553,81 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); std::fs::remove_file(&path).ok(); } + + #[test] + fn export_guard_refuses_vault_path_and_leaves_it_untouched() { + // Aiming an export at the live vault file must be refused BEFORE any write — overwriting + // the vault with an export sealed under a different password would lock the user out. + let vault = tmp_export("guard-vault"); + std::fs::write(&vault, b"REAL-VAULT-BYTES").unwrap(); + assert!(matches!( + guard_not_vault_path(&vault, &vault), + Err(VaultError::WouldOverwriteVault) + )); + // The guard performs no write: the vault file is byte-for-byte intact. + assert_eq!(std::fs::read(&vault).unwrap(), b"REAL-VAULT-BYTES"); + std::fs::remove_file(&vault).ok(); + } + + #[test] + fn export_guard_allows_a_different_path() { + let vault = tmp_export("guard-vault-ok"); + std::fs::write(&vault, b"x").unwrap(); + let other = tmp_export("guard-other"); + assert!(guard_not_vault_path(&vault, &other).is_ok()); + std::fs::remove_file(&vault).ok(); + } + + #[test] + fn export_guard_matches_noncanonical_spelling() { + // Reaching the same file via a "./name" spelling must still be caught. + let vault = tmp_export("guard-noncanon"); + std::fs::write(&vault, b"x").unwrap(); + let dir = vault.parent().unwrap(); + let name = vault.file_name().unwrap(); + let spelled = dir.join(".").join(name); + assert!(matches!( + guard_not_vault_path(&vault, &spelled), + Err(VaultError::WouldOverwriteVault) + )); + std::fs::remove_file(&vault).ok(); + } + + #[test] + fn export_guard_refuses_dotdot_through_missing_dir() { + // Regression: a target spelled through a NON-existent intermediate dir + // (`/nope/../vault`) must be refused. Otherwise the guard misses it, and the + // encrypted export's create_dir_all materializes `nope/` and the rename resolves + // `nope/..` back onto the vault — overwriting it under a different password (lockout). + let vault = tmp_export("guard-dotdot"); + std::fs::write(&vault, b"REAL-VAULT").unwrap(); + let dir = vault.parent().unwrap(); + let name = vault.file_name().unwrap(); + let sneaky = dir.join("nope-does-not-exist").join("..").join(name); + assert!(matches!( + guard_not_vault_path(&vault, &sneaky), + Err(VaultError::WouldOverwriteVault) + )); + // The guard must not have created the decoy dir as a side effect. + assert!(!dir.join("nope-does-not-exist").exists()); + std::fs::remove_file(&vault).ok(); + } + + #[test] + fn export_guard_refuses_hardlink_to_vault() { + // A hardlink to the vault is a different name for the same inode; canonicalize can't + // see it, so device+inode identity must catch it — otherwise a plaintext export would + // truncate the shared inode and brick the vault. + let vault = tmp_export("guard-hardlink-vault"); + std::fs::write(&vault, b"REAL-VAULT").unwrap(); + let link = tmp_export("guard-hardlink-alias"); + std::fs::remove_file(&link).ok(); + std::fs::hard_link(&vault, &link).unwrap(); + assert!(matches!( + guard_not_vault_path(&vault, &link), + Err(VaultError::WouldOverwriteVault) + )); + std::fs::remove_file(&link).ok(); + std::fs::remove_file(&vault).ok(); + } } diff --git a/src-tauri/src/vault/crypto.rs b/src-tauri/src/vault/crypto.rs index 361c4b5..384ffe2 100644 --- a/src-tauri/src/vault/crypto.rs +++ b/src-tauri/src/vault/crypto.rs @@ -29,6 +29,9 @@ pub enum VaultError { /// The file is encrypted but no password was supplied. The frontend uses this /// (serialized as the bare string `"NeedsPassword"`) to decide to prompt. NeedsPassword, + /// An export was aimed at the live vault file; refused before writing so it can't + /// overwrite the vault and lock the user out. Serialized as `"WouldOverwriteVault"`. + WouldOverwriteVault, Io(String), } @@ -42,6 +45,9 @@ impl std::fmt::Display for VaultError { VaultError::BadFormat => write!(f, "invalid vault file format"), VaultError::Locked => write!(f, "vault is locked (unlock first)"), VaultError::NeedsPassword => write!(f, "file is encrypted; a password is required"), + VaultError::WouldOverwriteVault => { + write!(f, "refusing to export over the live vault file") + } VaultError::Io(e) => write!(f, "I/O error: {e}"), } } @@ -99,6 +105,31 @@ mod tests { assert!(matches!(open(&bad, &nonce, &ct), Err(VaultError::Crypto))); } + #[test] + fn vault_error_unit_variants_serialize_to_bare_strings() { + // The frontend (App.tsx) classifies errors by matching the serialized VaultError against + // bare strings like "NeedsPassword" / "Crypto". serde serializes a fieldless enum variant + // as its bare name; pin that here so a future rename breaks THIS build instead of silently + // breaking the import/export UX. + assert_eq!(serde_json::to_string(&VaultError::Crypto).unwrap(), "\"Crypto\""); + assert_eq!(serde_json::to_string(&VaultError::BadFormat).unwrap(), "\"BadFormat\""); + assert_eq!(serde_json::to_string(&VaultError::Locked).unwrap(), "\"Locked\""); + assert_eq!( + serde_json::to_string(&VaultError::NeedsPassword).unwrap(), + "\"NeedsPassword\"" + ); + assert_eq!( + serde_json::to_string(&VaultError::WouldOverwriteVault).unwrap(), + "\"WouldOverwriteVault\"" + ); + // The one payload-carrying variant stays an object: {"Io":"..."} — the frontend's error + // formatter special-cases this shape. + assert_eq!( + serde_json::to_string(&VaultError::Io("boom".into())).unwrap(), + "{\"Io\":\"boom\"}" + ); + } + #[test] fn tampered_ciphertext_fails() { let key = derive_key("x", &[1u8; 16], KdfParams::default()); diff --git a/src-tauri/src/vault/file.rs b/src-tauri/src/vault/file.rs index 3adabc0..e886497 100644 --- a/src-tauri/src/vault/file.rs +++ b/src-tauri/src/vault/file.rs @@ -173,6 +173,22 @@ mod tests { buf } + #[test] + fn kdf_defaults_and_version_are_pinned() { + // `open_document` rejects any header whose KDF params != `KdfParams::default()`, and it is + // shared by the live-vault load path. So changing these defaults would make EVERY existing + // vault and export stop opening. If you change them you MUST bump VERSION and add a + // migration branch in `open_document`. This test pins the coupling so it can't drift + // silently. + let d = KdfParams::default(); + assert_eq!( + (d.m_cost, d.t_cost, d.p_cost), + (19456, 2, 1), + "KDF defaults changed — bump VERSION and migrate in open_document" + ); + assert_eq!(VERSION, 1, "on-disk VERSION changed — add a migration path"); + } + #[test] fn open_document_rejects_nondefault_params() { // A header whose KDF params aren't the ones we write must be rejected as BadFormat diff --git a/src-tauri/src/vault/manager.rs b/src-tauri/src/vault/manager.rs index 8822a3a..5f44e34 100644 --- a/src-tauri/src/vault/manager.rs +++ b/src-tauri/src/vault/manager.rs @@ -28,6 +28,11 @@ impl VaultManager { self.inner.lock().unwrap().is_some() } + /// Path of the vault file on disk. Used to refuse exports that would overwrite it. + pub fn path(&self) -> &std::path::Path { + &self.path + } + /// `true` se já existe um cofre em disco. Permite ao UI distinguir /// "definir senha mestra" (primeiro uso) de "destravar" (cofre existente). pub fn vault_exists(&self) -> bool { diff --git a/src/App.tsx b/src/App.tsx index 6f4538c..b891880 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ import { MenuBar, type Menu } from "./components/MenuBar"; import { findConnWithChain, findNode, findParentId, isInSubtree, nodeExists } from "./lib/tree"; import { resolveCreds, resolveGateway } from "./lib/inherit"; import type { Node } from "./lib/vaultApi"; -import { vaultApi } from "./lib/vaultApi"; +import { vaultApi, vaultErrorText } from "./lib/vaultApi"; import { confirm, open, save } from "@tauri-apps/plugin-dialog"; import { colors } from "./components/styles"; @@ -156,7 +156,12 @@ export default function App() { const r = await vaultApi.exportConnections(path); setNotice(r.message); } catch (e) { - setNotice("Export failed: " + String(e)); + const err = vaultErrorText(e); + if (err.includes("WouldOverwriteVault")) { + setNotice("That's your live vault file — pick a different file for the export."); + return; + } + setNotice("Export failed: " + err); } } @@ -172,7 +177,12 @@ export default function App() { const r = await vaultApi.exportConnectionsEncrypted(path, pw); setNotice(r.message); } catch (e) { - setNotice("Export failed: " + String(e)); + const err = vaultErrorText(e); + if (err.includes("WouldOverwriteVault")) { + setNotice("That's your live vault file — pick a different file for the export."); + return; + } + setNotice("Export failed: " + err); } } @@ -186,7 +196,7 @@ export default function App() { await v.refresh(); setNotice(r.message); } catch (e) { - const err = String(e); + const err = vaultErrorText(e); if (err.includes("NeedsPassword")) { const typed = await promptPassword(name); if (typed === null) return; // cancelled → no import diff --git a/src/lib/vaultApi.ts b/src/lib/vaultApi.ts index 880b73b..5b9aab6 100644 --- a/src/lib/vaultApi.ts +++ b/src/lib/vaultApi.ts @@ -70,6 +70,23 @@ export interface UpdatePolicy { currentVersion: string; } +/** A readable string for a rejected vault command. `VaultError`'s unit variants arrive as a + * bare string (e.g. "Crypto", "NeedsPassword", "WouldOverwriteVault"); the one payload variant + * arrives as `{ Io: "..." }`, which `String(e)` would render as "[object Object]". */ +export function vaultErrorText(e: unknown): string { + if (typeof e === "string") return e; + if (e && typeof e === "object") { + const io = (e as { Io?: unknown }).Io; + if (typeof io === "string") return io; + try { + return JSON.stringify(e); + } catch { + /* fall through */ + } + } + return String(e); +} + // Wrappers tipados dos comandos Tauri (Tauri v2 converte snake_case→camelCase nos args). export const vaultApi = { exists: () => invoke("vault_exists"),