//! Открытое хранилище: чтение/запись файла и синхронизация. //! //! Синхронизация рассчитана на «папку в облаке» (Dropbox, OneDrive, Google Drive, //! Syncthing, Nextcloud…): если файл на диске изменился извне, перед записью //! он расшифровывается тем же ключом и сливается с текущей версией. use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::SystemTime; use anyhow::{Context, Result, anyhow}; use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::crypto::{self, VaultKey}; use crate::vault::{MergeReport, Vault}; /// Файл на диске зашифрован другим мастер-паролем (сменили на другом устройстве). #[derive(Debug)] pub struct ForeignKey; impl std::fmt::Display for ForeignKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("Файл хранилища изменён с другим мастер-паролем") } } impl std::error::Error for ForeignKey {} #[derive(Clone, Copy, PartialEq)] struct Stamp { len: u64, mtime: Option, } fn stamp(path: &Path) -> Option { let m = fs::metadata(path).ok()?; Some(Stamp { len: m.len(), mtime: m.modified().ok() }) } fn hash(bytes: &[u8]) -> [u8; 32] { Sha256::digest(bytes).into() } fn parse_vault(plain: &[u8]) -> Result { serde_json::from_slice(plain).context("Не удалось прочитать содержимое хранилища") } fn serialize(vault: &Vault, key: &VaultKey) -> Result> { let plain = Zeroizing::new(serde_json::to_vec(vault)?); crypto::encrypt(&plain, key) } /// Атомарная запись: старая версия → `.bak`, новая пишется во временный файл /// и переименовывается поверх. Сбой посреди записи не портит хранилище. pub fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { fs::create_dir_all(dir)?; } let tmp = path.with_extension("qulyp.tmp"); { let mut f = fs::File::create(&tmp).with_context(|| format!("Не удалось записать {}", tmp.display()))?; f.write_all(data)?; f.sync_all()?; } if path.exists() { let _ = fs::copy(path, path.with_extension("qulyp.bak")); } fs::rename(&tmp, path).with_context(|| format!("Не удалось сохранить {}", path.display()))?; Ok(()) } /// Прочитать и расшифровать файл паролем. Медленно (Argon2) — вызывать не из UI-потока. pub fn load(path: &Path, password: &str) -> Result<(Vault, VaultKey, Vec)> { let bytes = fs::read(path).with_context(|| format!("Не удалось открыть {}", path.display()))?; let header = crypto::read_header(&bytes)?; let key = VaultKey::derive(password, header.salt, header.params)?; let plain = crypto::decrypt(&bytes, &key)?; Ok((parse_vault(&plain)?, key, bytes)) } pub fn same_file(a: &Path, b: &Path) -> bool { match (a.canonicalize(), b.canonicalize()) { (Ok(a), Ok(b)) => a == b, _ => a == b, } } pub struct Session { pub path: PathBuf, pub key: VaultKey, pub vault: Vault, disk_stamp: Option, disk_hash: [u8; 32], } impl Session { pub fn open(path: PathBuf, password: &str) -> Result { let (vault, key, bytes) = load(&path, password)?; Ok(Self { disk_stamp: stamp(&path), disk_hash: hash(&bytes), path, key, vault }) } pub fn create(path: PathBuf, name: &str, password: &str) -> Result { let key = VaultKey::generate(password)?; let mut s = Self { path, key, vault: Vault::new(name), disk_stamp: None, disk_hash: [0; 32] }; s.write()?; Ok(s) } pub fn file_name(&self) -> String { self.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() } fn write(&mut self) -> Result<()> { self.vault.normalize(); let data = serialize(&self.vault, &self.key)?; write_atomic(&self.path, &data)?; self.disk_stamp = stamp(&self.path); self.disk_hash = hash(&data); Ok(()) } /// Байты файла, если он изменился с момента последнего чтения/записи. fn changed_on_disk(&mut self) -> Result>> { let now = stamp(&self.path); if now.is_none() || now == self.disk_stamp { return Ok(None); } let bytes = fs::read(&self.path)?; if hash(&bytes) == self.disk_hash { self.disk_stamp = now; // тронули mtime, но содержимое то же return Ok(None); } Ok(Some(bytes)) } fn decrypt_foreign(&self, bytes: &[u8]) -> Result { let header = crypto::read_header(bytes)?; if !self.key.matches(&header) { return Err(ForeignKey.into()); } parse_vault(&crypto::decrypt(bytes, &self.key)?) } /// Сохранить, предварительно влив изменения, сделанные в файле извне. pub fn save(&mut self) -> Result { let mut report = MergeReport::default(); if let Some(bytes) = self.changed_on_disk()? { let other = self.decrypt_foreign(&bytes)?; report = self.vault.merge(&other); } self.write()?; Ok(report) } /// Перезаписать файл текущей версией без слияния. pub fn force_save(&mut self) -> Result<()> { self.write() } /// Проверить файл на внешние изменения и подтянуть их. /// `Ok(None)` — ничего не менялось. pub fn poll(&mut self) -> Result> { let Some(bytes) = self.changed_on_disk()? else { return Ok(None) }; let mut other = self.decrypt_foreign(&bytes)?; let report = self.vault.merge(&other); other.normalize(); if self.vault == other { self.disk_stamp = stamp(&self.path); self.disk_hash = hash(&bytes); } else { // У нас есть что-то, чего нет в файле, — записываем объединённую версию. self.write()?; } Ok(Some(report)) } /// Слить с другим файлом, уже расшифрованным его ключом. /// Если это наш же файл (сменили пароль на другом устройстве) — переходим на его ключ. pub fn merge_other( &mut self, other: &Vault, other_path: &Path, other_key: &VaultKey, write_back: bool, ) -> Result { let report = self.vault.merge(other); if same_file(other_path, &self.path) { self.key = other_key.clone(); self.write()?; } else { self.save()?; if write_back { write_atomic(other_path, &serialize(&self.vault, other_key)?)?; } } Ok(report) } /// Прочитать другой файл нашим ключом (когда у копии тот же пароль и соль). pub fn load_with_own_key(&self, path: &Path) -> Result { let bytes = fs::read(path).with_context(|| format!("Не удалось открыть {}", path.display()))?; self.decrypt_foreign(&bytes).map_err(|e| { if e.is::() { anyhow!("У этого файла другой пароль — введите его") } else { e } }) } pub fn change_key(&mut self, key: VaultKey) -> Result<()> { self.save()?; self.key = key; self.write() } } #[cfg(test)] mod tests { use super::*; use crate::vault::Entry; fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("qulyp-test-{}-{name}", std::process::id())); let _ = fs::remove_dir_all(&d); fs::create_dir_all(&d).unwrap(); d.join("v.qulyp") } #[test] fn two_devices_sync_through_one_file() { let path = tmp("sync"); let mut a = Session::create(path.clone(), "t", "pw").unwrap(); let mut b = Session::open(path.clone(), "pw").unwrap(); let mut e = Entry::default(); e.title = "from A".into(); a.vault.upsert(e); a.save().unwrap(); let mut e = Entry::default(); e.title = "from B".into(); b.vault.upsert(e); // B не видел изменений A — при сохранении должен их влить, а не затереть. let r = b.save().unwrap(); assert_eq!(r.added, 1); assert_eq!(b.vault.entries.len(), 2); let r = a.poll().unwrap().unwrap(); assert_eq!(r.added, 1); assert_eq!(a.vault.entries.len(), 2); let reopened = Session::open(path, "pw").unwrap(); assert_eq!(reopened.vault.entries.len(), 2); } /// Демо-хранилище для ручной проверки UI (пароль `demo-password`): /// `QULYP_DEMO=путь.qulyp cargo test make_demo -- --ignored` #[test] #[ignore] fn make_demo() { let path = PathBuf::from(std::env::var("QULYP_DEMO").expect("укажите QULYP_DEMO")); let _ = fs::remove_file(&path); let mut s = Session::create(path, "Личное", "demo-password").unwrap(); let work = s.vault.add_group("Работа"); let social = s.vault.add_group("Соцсети"); let items = [ ("GitHub", "dev@example.com", "gh_Tq8#vLz2!mPx", "github.com", Some(work), true, "JBSWY3DPEHPK3PXP"), ("Почта Gmail", "me@gmail.com", "Kx9$wQ2mZr7!", "mail.google.com", None, true, ""), ("Jira", "ivanov", "qwerty123", "company.atlassian.net", Some(work), false, ""), ("Telegram", "+7 700 000 00 00", "Tg-2026-secure!", "web.telegram.org", Some(social), false, ""), ("Instagram", "my.photos", "InstaPass!42", "instagram.com", Some(social), false, ""), ("Wi-Fi дома", "", "HomeNet_5G_2026", "", None, false, ""), ("Kaspi", "+7 701 111 22 33", "K@spi!Bank#77", "kaspi.kz", None, true, ""), ]; for (t, u, p, url, g, fav, totp) in items { let mut e = Entry::default(); e.title = t.into(); e.username = u.into(); e.password = p.into(); e.url = url.into(); e.group = g; e.favorite = fav; e.totp = totp.into(); if t == "Wi-Fi дома" { e.notes = "Роутер в прихожей, админка 192.168.1.1".into(); } s.vault.upsert(e); } s.save().unwrap(); } #[test] fn password_change_elsewhere_is_detected() { let path = tmp("foreign"); let mut a = Session::create(path.clone(), "t", "old").unwrap(); let mut b = Session::open(path.clone(), "old").unwrap(); b.change_key(VaultKey::generate("new").unwrap()).unwrap(); let err = a.poll().unwrap_err(); assert!(err.is::()); } }