Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 158 additions & 9 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use serde::Serialize;
Expand Down Expand Up @@ -132,10 +132,68 @@ pub fn import_mremoteng(state: State<AppState>, path: String) -> Result<ImportRe
})
}

/// Resolve `p` to an absolute, symlink- and `..`-free path for a same-file comparison. If `p`
/// exists it is canonicalized whole; otherwise (an export target need not exist yet) the longest
/// existing ancestor is canonicalized and the remaining, not-yet-created components are applied
/// lexically — resolving `.` and `..`. This matters: a target spelled `<dir>/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 `<dir>/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<PathBuf> {
use std::path::Component;
if let Ok(c) = std::fs::canonicalize(p) {
return Some(c);
}
let mut ancestor = p;
let mut tail: Vec<Component> = 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<AppState>, path: String) -> Result<ImportReport, VaultError> {
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()))?;
Expand Down Expand Up @@ -189,6 +247,7 @@ pub fn export_connections_encrypted(
path: String,
password: String,
) -> Result<ImportReport, VaultError> {
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);
Expand All @@ -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<Document, VaultError> {
/// 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))
}
}

Expand All @@ -221,11 +284,16 @@ pub fn import_remota_json(
password: Option<String>,
) -> Result<ImportReport, VaultError> {
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}."),
})
}

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
// (`<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();
}
}
31 changes: 31 additions & 0 deletions src-tauri/src/vault/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand All @@ -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}"),
}
}
Expand Down Expand Up @@ -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());
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/src/vault/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/vault/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 14 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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);
}
}

Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/lib/vaultApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>("vault_exists"),
Expand Down