Compare commits
No commits in common. "55c98747e3ae7412924e171d4a18572ea30248b8" and "b9183f7dd23c4212638c66527d1c2cef3b024bcb" have entirely different histories.
55c98747e3
...
b9183f7dd2
@ -1,26 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
linux:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Системные пакеты
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y libxkbcommon-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libgl1-mesa-dev
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- run: cargo test
|
|
||||||
- run: cargo build --release
|
|
||||||
|
|
||||||
windows:
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- run: cargo test
|
|
||||||
- run: cargo build --release
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,6 +0,0 @@
|
|||||||
/target
|
|
||||||
|
|
||||||
# Никогда не коммитить хранилища паролей и их копии
|
|
||||||
*.qulyp
|
|
||||||
*.qulyp.bak
|
|
||||||
*.qulyp.tmp
|
|
||||||
37
CLAUDE.md
37
CLAUDE.md
@ -1,37 +0,0 @@
|
|||||||
# Qulyp — памятка для ИИ-ассистента
|
|
||||||
|
|
||||||
Менеджер паролей на Rust + egui (eframe 0.36, рендер glow). Windows и Linux.
|
|
||||||
Один зашифрованный файл `.qulyp`, работает офлайн, синхронизация через облачную папку.
|
|
||||||
|
|
||||||
## Заметки проекта (`notes/`) — читать в начале сессии
|
|
||||||
| Файл | Что внутри | Лимит строк |
|
|
||||||
|---|---|---|
|
|
||||||
| `notes/STATUS.md` | Где проект сейчас, что работает | 40 |
|
|
||||||
| `notes/PLAN.md` | Планы: Сейчас / Дальше / Потом | 60 |
|
|
||||||
| `notes/DEBT.md` | Хвосты: баги, техдолг, недоделки | 60 |
|
|
||||||
| `notes/LOG.md` | Журнал сессий, новые сверху | 80 |
|
|
||||||
| `notes/DECISIONS.md` | Ключевые решения и почему | 80 |
|
|
||||||
| `notes/DATA.md` | Формат файла и схема данных («БД») | 80 |
|
|
||||||
|
|
||||||
Лимиты проверяет `cargo test --test notes` (заголовок `<!-- limit: N -->` в каждом файле).
|
|
||||||
`notes/archive/` — старое, **не читать** без необходимости.
|
|
||||||
|
|
||||||
## Правила ведения заметок
|
|
||||||
- В конце каждой рабочей сессии: 1–4 строки в `LOG.md` (дата, что сделано), обновить `STATUS.md`.
|
|
||||||
- Закрытый хвост — удалить из `DEBT.md` (не зачёркивать). Новый — добавить одной строкой.
|
|
||||||
- Пишем сжато: одна мысль — одна строка, без пересказа кода и git-истории.
|
|
||||||
- Упёрлись в лимит `LOG.md` → старые записи перенести в `notes/archive/LOG-<год>.md`.
|
|
||||||
- В других файлах при лимите — сократить формулировки, а не архивировать.
|
|
||||||
|
|
||||||
## Код
|
|
||||||
- `src/crypto.rs` — формат файла, Argon2id + XChaCha20-Poly1305
|
|
||||||
- `src/vault.rs` — модель данных и слияние версий (синхронизация)
|
|
||||||
- `src/storage.rs` — сессия: атомарная запись, `.bak`, автослияние внешних изменений
|
|
||||||
- `src/app.rs` — экраны и UI; `src/widgets.rs`, `src/theme.rs` — виджеты и стиль
|
|
||||||
- `src/devshots.rs` — dev-сценарий со скриншотами (feature `dev-shots`)
|
|
||||||
|
|
||||||
## Команды
|
|
||||||
- `cargo test` — все тесты; `cargo run` — запуск; `cargo build --release` — релиз
|
|
||||||
- `cargo check --target x86_64-unknown-linux-gnu` — проверка сборки под Linux
|
|
||||||
- Скриншоты UI: см. шапку `src/devshots.rs`. **Не** эмулировать системные нажатия клавиш/мыши
|
|
||||||
для проверки GUI — они могут уйти в чужое окно.
|
|
||||||
3855
Cargo.lock
generated
3855
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
46
Cargo.toml
46
Cargo.toml
@ -1,46 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "qulyp"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
rust-version = "1.85"
|
|
||||||
description = "Qulyp — локальный менеджер паролей с шифрованным файлом и синхронизацией"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "1"
|
|
||||||
arboard = { version = "3.6", default-features = false, features = ["windows-sys"] }
|
|
||||||
argon2 = "0.5"
|
|
||||||
chacha20poly1305 = "0.10"
|
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
|
||||||
dirs = "6"
|
|
||||||
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "links", "wayland", "x11"] }
|
|
||||||
hmac = "0.12"
|
|
||||||
rand = "0.8"
|
|
||||||
rfd = "0.17"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
sha1 = "0.10"
|
|
||||||
sha2 = "0.10"
|
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
|
||||||
zeroize = "1.8"
|
|
||||||
|
|
||||||
# Argon2 в debug-сборке без оптимизаций работает очень медленно
|
|
||||||
[profile.dev.package."*"]
|
|
||||||
opt-level = 3
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = 3
|
|
||||||
lto = true
|
|
||||||
codegen-units = 1
|
|
||||||
strip = true
|
|
||||||
|
|
||||||
# На Linux (Wayland) буферу обмена нужен отдельный бэкенд
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
|
|
||||||
|
|
||||||
[dependencies.png]
|
|
||||||
version = "0.17"
|
|
||||||
optional = true
|
|
||||||
|
|
||||||
[features]
|
|
||||||
# Только для разработки: сценарий + скриншоты окна (см. src/devshots.rs)
|
|
||||||
dev-shots = ["dep:png"]
|
|
||||||
57
README.md
57
README.md
@ -1,56 +1,3 @@
|
|||||||
# Qulyp
|
# AspansQulyp
|
||||||
|
|
||||||
Локальный менеджер паролей для Windows и Linux — простая альтернатива KeePass.
|
AspansQulyp
|
||||||
Все данные лежат в одном зашифрованном файле `.qulyp`, работает без интернета и без серверов.
|
|
||||||
|
|
||||||
## Возможности
|
|
||||||
- **Шифрование:** ключ из мастер-пароля через Argon2id (64 МиБ, 3 прохода), данные — XChaCha20-Poly1305
|
|
||||||
- **Записи:** логин, пароль, сайт, коды 2FA (TOTP), заметки, папки, избранное, история паролей
|
|
||||||
- **Генератор паролей** и индикатор стойкости
|
|
||||||
- **Буфер обмена** очищается сам через 20 с (на Windows пароль не попадает в журнал Win+V)
|
|
||||||
- **Автоблокировка** при бездействии и (по желанию) при сворачивании окна
|
|
||||||
- **Синхронизация** между устройствами через любую облачную папку
|
|
||||||
- Тёмная и светлая тема, горячие клавиши
|
|
||||||
|
|
||||||
## Синхронизация
|
|
||||||
Положите файл хранилища в папку Dropbox, OneDrive, Google Drive, Syncthing или Nextcloud.
|
|
||||||
Qulyp каждые 2 секунды проверяет, не изменился ли файл, и **сливает** изменения, а не затирает их:
|
|
||||||
для каждой записи побеждает более свежая версия, старый пароль попадает в историю, удаления тоже синхронизируются.
|
|
||||||
|
|
||||||
Кнопка 🔄 вручную объединяет с любым другим файлом `.qulyp` — например, с копией на флешке
|
|
||||||
или с конфликтной копией, которую создало облако.
|
|
||||||
|
|
||||||
## Горячие клавиши
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| `Ctrl+F` | поиск |
|
|
||||||
| `Ctrl+N` | новая запись |
|
|
||||||
| `Ctrl+C` / двойной клик | копировать пароль |
|
|
||||||
| `Ctrl+B` | копировать логин |
|
|
||||||
| `Ctrl+G` | генератор паролей |
|
|
||||||
| `Ctrl+L` | заблокировать |
|
|
||||||
| `↑` `↓`, `Del`, `Esc` | навигация, удаление, отмена |
|
|
||||||
|
|
||||||
## Сборка
|
|
||||||
Нужен Rust 1.85+ (<https://rustup.rs>).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
Готовый файл: `target/release/qulyp.exe` (Windows) или `target/release/qulyp` (Linux).
|
|
||||||
|
|
||||||
Для Linux (Debian/Ubuntu) нужны пакеты:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install libxkbcommon-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libgl1-mesa-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Тесты: `cargo test`.
|
|
||||||
|
|
||||||
## Важно
|
|
||||||
Мастер-пароль **нельзя восстановить**. Без него данные не расшифровать — ни вам, ни кому-либо ещё.
|
|
||||||
Делайте резервные копии файла: рядом с ним Qulyp хранит прошлую версию `*.qulyp.bak`.
|
|
||||||
|
|
||||||
## Для разработчиков
|
|
||||||
Состояние проекта, планы и хвосты — в [`notes/`](notes/), правила ведения — в [`CLAUDE.md`](CLAUDE.md).
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<!-- limit: 80 -->
|
|
||||||
# Данные
|
|
||||||
|
|
||||||
## Файл `.qulyp`
|
|
||||||
| Байты | Поле |
|
|
||||||
|---|---|
|
|
||||||
| 0–7 | magic `QULYPv1\0` |
|
|
||||||
| 8–19 | Argon2id: m_kib, t, p (u32 LE) |
|
|
||||||
| 20–35 | salt (16) |
|
|
||||||
| 36–59 | nonce XChaCha20 (24) |
|
|
||||||
| 60– | шифротекст JSON + тег Poly1305 (16) |
|
|
||||||
|
|
||||||
Рядом при сохранении: `.qulyp.bak` (прошлая версия), временный `.qulyp.tmp` (атомарная замена).
|
|
||||||
|
|
||||||
## JSON хранилища (`Vault`)
|
|
||||||
- `format` (1), `name`, `created` (мс)
|
|
||||||
- `entries[]`: `id` (uuid), `title`, `username`, `password`, `url`, `totp`, `notes`,
|
|
||||||
`group` (uuid|null), `favorite`, `created`, `modified` (мс), `history[] {password, changed}`
|
|
||||||
- `groups[]`: `id`, `name`, `modified`
|
|
||||||
- `tombstones {uuid: мс}` — удалённые записи и папки
|
|
||||||
|
|
||||||
Все поля с `serde(default)` — старые файлы читаются новыми версиями.
|
|
||||||
|
|
||||||
## Настройки (не секретные)
|
|
||||||
`%APPDATA%\Qulyp\config.json` / `~/.config/qulyp/config.json` (путь меняется `QULYP_CONFIG`):
|
|
||||||
недавние файлы, тема, автоблокировка, очистка буфера, опции генератора.
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<!-- limit: 60 -->
|
|
||||||
# Хвосты (баги, техдолг, недоделки)
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
- Строки пароля в egui `TextEdit` могут оставлять копии в памяти — zeroize только «по возможности»
|
|
||||||
- Слияние по времени изменения (LWW): при сильно сбитых часах на устройстве может победить не та версия
|
|
||||||
- Надгробия (tombstones) удалённых записей никогда не чистятся — файл медленно растёт
|
|
||||||
|
|
||||||
## Платформы
|
|
||||||
- Linux: буфер обмена на X11 очищается при закрытии приложения; на GNOME Wayland нет data-control → идёт через XWayland
|
|
||||||
- Linux: шрифты ищутся по фиксированным путям (Inter/Noto/DejaVu), иначе встроенные egui
|
|
||||||
- Автоблокировка «при сворачивании» зависит от того, сообщает ли WM о minimized
|
|
||||||
|
|
||||||
## UX
|
|
||||||
- Оценка стойкости пароля грубая (без словаря)
|
|
||||||
- Переключение записи при открытом редакторе молча отбрасывает несохранённые правки
|
|
||||||
|
|
||||||
## Тесты
|
|
||||||
- UI покрыт только ручными скриншотами (dev-shots), автотестов интерфейса нет
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<!-- limit: 80 -->
|
|
||||||
# Решения
|
|
||||||
|
|
||||||
**egui/eframe вместо Tauri/Qt.** Чистый Rust, один бинарник, без WebView и Node; одинаково на Windows и Linux.
|
|
||||||
Рендер `glow` (OpenGL) — легче wgpu и быстрее собирается.
|
|
||||||
|
|
||||||
**Свой формат, а не .kdbx.** KDBX сложен (XML, много версий KDF/шифров). Свой формат — 60 байт заголовка
|
|
||||||
+ зашифрованный JSON, легко проверить. Импорт KDBX — отдельной задачей (см. PLAN).
|
|
||||||
|
|
||||||
**Argon2id 64 МиБ / 3 прохода / p=4.** Рекомендация OWASP-уровня, ~0.5 с на разблокировку.
|
|
||||||
Параметры пишутся в заголовок — можно усилить позже без смены формата.
|
|
||||||
|
|
||||||
**XChaCha20-Poly1305.** 24-байтный случайный nonce на каждое сохранение — без риска повтора.
|
|
||||||
Весь заголовок — AAD, подмена параметров ломает расшифровку.
|
|
||||||
|
|
||||||
**Синхронизация = файл в облачной папке.** Никакого своего сервера. Перед записью файл на диске
|
|
||||||
сливается с памятью; раз в 2 с проверяется внешнее изменение. Слияние — по записям, свежая побеждает,
|
|
||||||
старый пароль уходит в историю, удаления — через надгробия.
|
|
||||||
|
|
||||||
**Автосохранение после каждого изменения.** Нет кнопки «Сохранить» и риска забыть. Старая версия → `.qulyp.bak`.
|
|
||||||
|
|
||||||
**RustCrypto старых стабильных версий** (argon2 0.5, chacha20poly1305 0.10, hmac 0.12): новые мажорные
|
|
||||||
версии меняют API, а эти широко проверены.
|
|
||||||
|
|
||||||
**Язык интерфейса — русский.** Локализация — если понадобится.
|
|
||||||
|
|
||||||
**Строгий визуальный стиль.** Нейтрали делают работу, акцент (стальной синий) — только главная кнопка,
|
|
||||||
выделение, фокус и ссылки. Иконки рисуются вектором (`src/icons.rs`, сетка 16×16, одна толщина линии) —
|
|
||||||
шрифтовые эмодзи на разных системах выглядят по-разному.
|
|
||||||
13
notes/LOG.md
13
notes/LOG.md
@ -1,13 +0,0 @@
|
|||||||
<!-- limit: 80 -->
|
|
||||||
# Журнал (новые сверху)
|
|
||||||
|
|
||||||
## 2026-09-30 (дизайн)
|
|
||||||
- Строгий стиль: графитовая палитра, один приглушённый акцент, скругления 4–6 px, без теней на панелях
|
|
||||||
- Свои векторные иконки (src/icons.rs) вместо эмодзи; нейтральные монограммы вместо цветных аватаров
|
|
||||||
|
|
||||||
## 2026-09-30
|
|
||||||
- Создан проект: крипто (Argon2id + XChaCha20-Poly1305), модель данных, слияние, атомарная запись
|
|
||||||
- UI на egui 0.36: экраны приветствия/создания/разблокировки, основной экран, генератор, настройки
|
|
||||||
- Синхронизация через файл в облачной папке + ручное слияние, обработка смены пароля на другом устройстве
|
|
||||||
- 12 unit-тестов, dev-shots для скриншотов UI, проверка `cargo check` под Linux
|
|
||||||
- Система заметок `notes/` с лимитами строк (тест `tests/notes.rs`)
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<!-- limit: 60 -->
|
|
||||||
# План
|
|
||||||
|
|
||||||
## Сейчас
|
|
||||||
- [ ] Запустить и проверить на живом Linux (X11 и Wayland), поправить шрифты/буфер
|
|
||||||
- [ ] CI: сборка + тесты на Windows и Linux (workflow добавлен, проверить на сервере)
|
|
||||||
|
|
||||||
## Дальше
|
|
||||||
- [ ] Импорт/экспорт CSV (форматы Bitwarden, Chrome, KeePass)
|
|
||||||
- [ ] Сборка дистрибутивов: .msi/.exe-установщик, AppImage/.deb
|
|
||||||
- [ ] Ассоциация файлов `.qulyp` (двойной клик открывает хранилище)
|
|
||||||
- [ ] Автоподхват конфликтных копий облака (`… (conflicted copy).qulyp`)
|
|
||||||
|
|
||||||
## Потом
|
|
||||||
- [ ] Импорт .kdbx (KeePass)
|
|
||||||
- [ ] Вложения и произвольные поля
|
|
||||||
- [ ] Вложенные папки / теги
|
|
||||||
- [ ] Расширение браузера для автозаполнения
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<!-- limit: 40 -->
|
|
||||||
# Статус
|
|
||||||
|
|
||||||
**Версия:** 0.1.0 · **Обновлено:** 2026-09-30
|
|
||||||
|
|
||||||
## Работает
|
|
||||||
- Создание/открытие хранилища, мастер-пароль (мин. 8 символов), смена пароля
|
|
||||||
- Записи: название, логин, пароль, сайт, TOTP (2FA), заметки, папка, избранное
|
|
||||||
- История паролей (до 20 на запись), поиск, папки, горячие клавиши
|
|
||||||
- Генератор паролей + индикатор стойкости
|
|
||||||
- Буфер обмена с автоочисткой (на Windows — мимо журнала Win+V и облака)
|
|
||||||
- Автоблокировка по бездействию и при сворачивании
|
|
||||||
- Синхронизация: автоподхват изменений файла (облачная папка) + ручное слияние с другим файлом
|
|
||||||
- Обработка конфликта «пароль сменили на другом устройстве»
|
|
||||||
- Тёмная/светлая/системная тема
|
|
||||||
|
|
||||||
## Проверено
|
|
||||||
- Windows 11: сборка, 12 unit-тестов, UI вручную по скриншотам (dev-shots)
|
|
||||||
- Linux: только `cargo check` (запуск на живом Linux ещё не делали)
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
max_width = 120
|
|
||||||
use_small_heuristics = "Max"
|
|
||||||
2060
src/app.rs
2060
src/app.rs
File diff suppressed because it is too large
Load Diff
@ -1,88 +0,0 @@
|
|||||||
//! Настройки приложения (не секретные): недавние файлы, тема, таймеры.
|
|
||||||
//! Windows: %APPDATA%\Qulyp\config.json, Linux: ~/.config/qulyp/config.json.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::generator::GenOptions;
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Default)]
|
|
||||||
pub enum ThemeMode {
|
|
||||||
#[default]
|
|
||||||
System,
|
|
||||||
Dark,
|
|
||||||
Light,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct Config {
|
|
||||||
pub recent: Vec<PathBuf>,
|
|
||||||
pub theme: ThemeMode,
|
|
||||||
/// Автоблокировка при бездействии, минут (0 — никогда).
|
|
||||||
pub auto_lock_min: u32,
|
|
||||||
/// Очистка буфера обмена после копирования, секунд (0 — не очищать).
|
|
||||||
pub clipboard_sec: u32,
|
|
||||||
pub lock_on_minimize: bool,
|
|
||||||
pub generator: GenOptions,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Config {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
recent: Vec::new(),
|
|
||||||
theme: ThemeMode::System,
|
|
||||||
auto_lock_min: 10,
|
|
||||||
clipboard_sec: 20,
|
|
||||||
lock_on_minimize: false,
|
|
||||||
generator: GenOptions::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path() -> Option<PathBuf> {
|
|
||||||
// Переопределение — для тестов и «портативного» режима.
|
|
||||||
if let Some(p) = std::env::var_os("QULYP_CONFIG") {
|
|
||||||
return Some(PathBuf::from(p));
|
|
||||||
}
|
|
||||||
let dir = if cfg!(windows) { "Qulyp" } else { "qulyp" };
|
|
||||||
Some(dirs::config_dir()?.join(dir).join("config.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
pub fn load() -> Self {
|
|
||||||
path().and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&self) {
|
|
||||||
let Some(p) = path() else { return };
|
|
||||||
if let Some(dir) = p.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(dir);
|
|
||||||
}
|
|
||||||
if let Ok(json) = serde_json::to_vec_pretty(self) {
|
|
||||||
let _ = std::fs::write(p, json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_recent(&mut self, file: PathBuf) {
|
|
||||||
let file = file.canonicalize().map(strip_unc).unwrap_or(file);
|
|
||||||
self.recent.retain(|p| p != &file);
|
|
||||||
self.recent.insert(0, file);
|
|
||||||
self.recent.truncate(8);
|
|
||||||
self.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn default_vault_dir() -> PathBuf {
|
|
||||||
dirs::document_dir().or_else(dirs::home_dir).unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `canonicalize` на Windows возвращает `\\?\C:\...` — убираем префикс для читаемости.
|
|
||||||
fn strip_unc(p: PathBuf) -> PathBuf {
|
|
||||||
let s = p.to_string_lossy();
|
|
||||||
match s.strip_prefix(r"\\?\") {
|
|
||||||
Some(rest) if !rest.starts_with("UNC") => PathBuf::from(rest),
|
|
||||||
_ => p,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
172
src/crypto.rs
172
src/crypto.rs
@ -1,172 +0,0 @@
|
|||||||
//! Формат файла и шифрование.
|
|
||||||
//!
|
|
||||||
//! Файл `.qulyp`:
|
|
||||||
//! ```text
|
|
||||||
//! magic 8 байт "QULYPv1\0"
|
|
||||||
//! m_kib 4 байта LE — память Argon2id, КиБ
|
|
||||||
//! t 4 байта LE — число проходов
|
|
||||||
//! p 4 байта LE — параллелизм
|
|
||||||
//! salt 16 байт
|
|
||||||
//! nonce 24 байта — XChaCha20-Poly1305
|
|
||||||
//! data ... — зашифрованный JSON хранилища + 16 байт тега
|
|
||||||
//! ```
|
|
||||||
//! Весь заголовок передаётся как AAD, поэтому его подмена ломает расшифровку.
|
|
||||||
|
|
||||||
use anyhow::{Result, anyhow, bail};
|
|
||||||
use argon2::{Algorithm, Argon2, Params, Version};
|
|
||||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
|
||||||
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
|
|
||||||
use rand::RngCore;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
pub const MAGIC: &[u8; 8] = b"QULYPv1\0";
|
|
||||||
pub const HEADER_LEN: usize = 8 + 12 + 16 + 24;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct KdfParams {
|
|
||||||
pub m_kib: u32,
|
|
||||||
pub t: u32,
|
|
||||||
pub p: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for KdfParams {
|
|
||||||
fn default() -> Self {
|
|
||||||
// 64 МиБ, 3 прохода — примерно 0.3–0.7 с на обычном ПК.
|
|
||||||
Self { m_kib: 64 * 1024, t: 3, p: 4 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KdfParams {
|
|
||||||
fn validate(&self) -> Result<()> {
|
|
||||||
// Защита от файлов с абсурдными параметрами (DoS при открытии).
|
|
||||||
if !(8 * 1024..=2 * 1024 * 1024).contains(&self.m_kib)
|
|
||||||
|| !(1..=64).contains(&self.t)
|
|
||||||
|| !(1..=16).contains(&self.p)
|
|
||||||
{
|
|
||||||
bail!("Недопустимые параметры KDF в файле");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Header {
|
|
||||||
pub params: KdfParams,
|
|
||||||
pub salt: [u8; 16],
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_header(data: &[u8]) -> Result<Header> {
|
|
||||||
if data.len() < HEADER_LEN + 16 || &data[..8] != MAGIC {
|
|
||||||
bail!("Это не файл хранилища Qulyp");
|
|
||||||
}
|
|
||||||
let u32_at = |i: usize| u32::from_le_bytes(data[i..i + 4].try_into().unwrap());
|
|
||||||
let params = KdfParams { m_kib: u32_at(8), t: u32_at(12), p: u32_at(16) };
|
|
||||||
params.validate()?;
|
|
||||||
let mut salt = [0u8; 16];
|
|
||||||
salt.copy_from_slice(&data[20..36]);
|
|
||||||
Ok(Header { params, salt })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ключ, полученный из мастер-пароля. Держится в памяти, пока хранилище открыто.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct VaultKey {
|
|
||||||
key: Zeroizing<[u8; 32]>,
|
|
||||||
pub salt: [u8; 16],
|
|
||||||
pub params: KdfParams,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VaultKey {
|
|
||||||
pub fn derive(password: &str, salt: [u8; 16], params: KdfParams) -> Result<Self> {
|
|
||||||
params.validate()?;
|
|
||||||
let p = Params::new(params.m_kib, params.t, params.p, Some(32)).map_err(|e| anyhow!("Argon2: {e}"))?;
|
|
||||||
let mut key = Zeroizing::new([0u8; 32]);
|
|
||||||
Argon2::new(Algorithm::Argon2id, Version::V0x13, p)
|
|
||||||
.hash_password_into(password.as_bytes(), &salt, key.as_mut())
|
|
||||||
.map_err(|e| anyhow!("Argon2: {e}"))?;
|
|
||||||
Ok(Self { key, salt, params })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Новый ключ со свежей случайной солью.
|
|
||||||
pub fn generate(password: &str) -> Result<Self> {
|
|
||||||
let mut salt = [0u8; 16];
|
|
||||||
OsRng.fill_bytes(&mut salt);
|
|
||||||
Self::derive(password, salt, KdfParams::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Подходит ли этот ключ к файлу с данным заголовком (та же соль и параметры).
|
|
||||||
pub fn matches(&self, h: &Header) -> bool {
|
|
||||||
self.salt == h.salt && self.params == h.params
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Сравнение ключей без раннего выхода.
|
|
||||||
pub fn same_as(&self, other: &VaultKey) -> bool {
|
|
||||||
let mut diff = 0u8;
|
|
||||||
for (a, b) in self.key.iter().zip(other.key.iter()) {
|
|
||||||
diff |= a ^ b;
|
|
||||||
}
|
|
||||||
diff == 0 && self.salt == other.salt && self.params == other.params
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encrypt(plain: &[u8], key: &VaultKey) -> Result<Vec<u8>> {
|
|
||||||
let mut nonce = [0u8; 24];
|
|
||||||
OsRng.fill_bytes(&mut nonce);
|
|
||||||
|
|
||||||
let mut out = Vec::with_capacity(HEADER_LEN + plain.len() + 16);
|
|
||||||
out.extend_from_slice(MAGIC);
|
|
||||||
out.extend_from_slice(&key.params.m_kib.to_le_bytes());
|
|
||||||
out.extend_from_slice(&key.params.t.to_le_bytes());
|
|
||||||
out.extend_from_slice(&key.params.p.to_le_bytes());
|
|
||||||
out.extend_from_slice(&key.salt);
|
|
||||||
out.extend_from_slice(&nonce);
|
|
||||||
|
|
||||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(key.key.as_slice()));
|
|
||||||
let ct = cipher
|
|
||||||
.encrypt(XNonce::from_slice(&nonce), Payload { msg: plain, aad: &out[..HEADER_LEN] })
|
|
||||||
.map_err(|_| anyhow!("Ошибка шифрования"))?;
|
|
||||||
out.extend_from_slice(&ct);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decrypt(data: &[u8], key: &VaultKey) -> Result<Zeroizing<Vec<u8>>> {
|
|
||||||
let header = read_header(data)?;
|
|
||||||
if !key.matches(&header) {
|
|
||||||
bail!("Ключ не подходит к файлу");
|
|
||||||
}
|
|
||||||
let nonce = XNonce::from_slice(&data[36..HEADER_LEN]);
|
|
||||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(key.key.as_slice()));
|
|
||||||
let plain = cipher
|
|
||||||
.decrypt(nonce, Payload { msg: &data[HEADER_LEN..], aad: &data[..HEADER_LEN] })
|
|
||||||
.map_err(|_| anyhow!("Неверный пароль или файл повреждён"))?;
|
|
||||||
Ok(Zeroizing::new(plain))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn fast_key(pw: &str) -> VaultKey {
|
|
||||||
VaultKey::derive(pw, [7; 16], KdfParams { m_kib: 8 * 1024, t: 1, p: 1 }).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn roundtrip() {
|
|
||||||
let k = fast_key("secret");
|
|
||||||
let ct = encrypt(b"hello", &k).unwrap();
|
|
||||||
assert_eq!(&decrypt(&ct, &k).unwrap()[..], b"hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wrong_password_fails() {
|
|
||||||
let ct = encrypt(b"hello", &fast_key("a")).unwrap();
|
|
||||||
assert!(decrypt(&ct, &fast_key("b")).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tampered_header_fails() {
|
|
||||||
let k = fast_key("a");
|
|
||||||
let mut ct = encrypt(b"hello", &k).unwrap();
|
|
||||||
ct[40] ^= 1; // nonce
|
|
||||||
assert!(decrypt(&ct, &k).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
//! Только для разработки (feature `dev-shots`): сценарий действий и скриншоты
|
|
||||||
//! окна изнутри приложения — без эмуляции системных нажатий клавиш.
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! QULYP_CONFIG=tmp/config.json QULYP_DEV_DIR=tmp QULYP_DEV_STEPS="unlock:demo-password;shot:main;select:0;shot:details" \
|
|
||||||
//! cargo run --features dev-shots -- tmp/demo.qulyp
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use eframe::egui::{self, Event, UserData, ViewportCommand};
|
|
||||||
|
|
||||||
pub struct DevScript {
|
|
||||||
steps: VecDeque<String>,
|
|
||||||
dir: PathBuf,
|
|
||||||
wait: u32,
|
|
||||||
pending_shot: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DevScript {
|
|
||||||
pub fn from_env() -> Option<Self> {
|
|
||||||
let steps = std::env::var("QULYP_DEV_STEPS").ok()?;
|
|
||||||
let dir = PathBuf::from(std::env::var("QULYP_DEV_DIR").unwrap_or_else(|_| ".".into()));
|
|
||||||
Some(Self {
|
|
||||||
steps: steps.split(';').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect(),
|
|
||||||
dir,
|
|
||||||
wait: 20,
|
|
||||||
pending_shot: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Вызывается каждый кадр. Возвращает следующий шаг, который должно выполнить приложение.
|
|
||||||
pub fn tick(&mut self, ctx: &egui::Context) -> Option<String> {
|
|
||||||
ctx.request_repaint();
|
|
||||||
if let Some(name) = self.pending_shot.clone() {
|
|
||||||
let img = ctx.input(|i| {
|
|
||||||
i.events.iter().find_map(|e| match e {
|
|
||||||
Event::Screenshot { image, .. } => Some(image.clone()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if let Some(img) = img {
|
|
||||||
let path = self.dir.join(format!("{name}.png"));
|
|
||||||
save_png(&path, &img);
|
|
||||||
eprintln!("[dev] saved {}", path.display());
|
|
||||||
self.pending_shot = None;
|
|
||||||
self.wait = 3;
|
|
||||||
}
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if self.wait > 0 {
|
|
||||||
self.wait -= 1;
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let step = match self.steps.pop_front() {
|
|
||||||
Some(s) => s,
|
|
||||||
None => {
|
|
||||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Some(name) = step.strip_prefix("shot:") {
|
|
||||||
ctx.send_viewport_cmd(ViewportCommand::Screenshot(UserData::default()));
|
|
||||||
self.pending_shot = Some(name.to_owned());
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if let Some(n) = step.strip_prefix("wait:") {
|
|
||||||
self.wait = n.parse().unwrap_or(10);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.wait = 12;
|
|
||||||
Some(step)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_png(path: &std::path::Path, img: &egui::ColorImage) {
|
|
||||||
let file = std::fs::File::create(path).expect("create png");
|
|
||||||
let mut enc = png::Encoder::new(std::io::BufWriter::new(file), img.size[0] as u32, img.size[1] as u32);
|
|
||||||
enc.set_color(png::ColorType::Rgba);
|
|
||||||
enc.set_depth(png::BitDepth::Eight);
|
|
||||||
let data: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_array()).collect();
|
|
||||||
enc.write_header().and_then(|mut w| w.write_image_data(&data)).expect("write png");
|
|
||||||
}
|
|
||||||
127
src/generator.rs
127
src/generator.rs
@ -1,127 +0,0 @@
|
|||||||
//! Генератор паролей и оценка их стойкости.
|
|
||||||
|
|
||||||
use rand::Rng;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
use rand::seq::SliceRandom;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
const LOWER: &str = "abcdefghijklmnopqrstuvwxyz";
|
|
||||||
const UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
||||||
const DIGITS: &str = "0123456789";
|
|
||||||
const SYMBOLS: &str = "!@#$%^&*()-_=+[]{};:,.?/~";
|
|
||||||
const AMBIGUOUS: &str = "Il1O0o|";
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, PartialEq)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct GenOptions {
|
|
||||||
pub length: usize,
|
|
||||||
pub lower: bool,
|
|
||||||
pub upper: bool,
|
|
||||||
pub digits: bool,
|
|
||||||
pub symbols: bool,
|
|
||||||
pub avoid_ambiguous: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GenOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self { length: 20, lower: true, upper: true, digits: true, symbols: true, avoid_ambiguous: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate(o: &GenOptions) -> String {
|
|
||||||
let pick =
|
|
||||||
|set: &str| -> Vec<char> { set.chars().filter(|c| !o.avoid_ambiguous || !AMBIGUOUS.contains(*c)).collect() };
|
|
||||||
let mut sets: Vec<Vec<char>> = [(o.lower, LOWER), (o.upper, UPPER), (o.digits, DIGITS), (o.symbols, SYMBOLS)]
|
|
||||||
.into_iter()
|
|
||||||
.filter(|(on, _)| *on)
|
|
||||||
.map(|(_, s)| pick(s))
|
|
||||||
.collect();
|
|
||||||
if sets.is_empty() {
|
|
||||||
sets.push(pick(LOWER));
|
|
||||||
}
|
|
||||||
let all: Vec<char> = sets.concat();
|
|
||||||
let len = o.length.clamp(4, 128);
|
|
||||||
let mut rng = OsRng;
|
|
||||||
|
|
||||||
// По одному символу из каждого включённого набора, остальное — из общего.
|
|
||||||
let mut out: Vec<char> = sets.iter().map(|s| s[rng.gen_range(0..s.len())]).collect();
|
|
||||||
while out.len() < len {
|
|
||||||
out.push(all[rng.gen_range(0..all.len())]);
|
|
||||||
}
|
|
||||||
out.truncate(len);
|
|
||||||
out.shuffle(&mut rng);
|
|
||||||
out.into_iter().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub struct Strength {
|
|
||||||
/// 0 — очень слабый … 4 — отличный.
|
|
||||||
pub score: u8,
|
|
||||||
pub bits: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Strength {
|
|
||||||
pub fn label(&self) -> &'static str {
|
|
||||||
["Очень слабый", "Слабый", "Средний", "Хороший", "Отличный"][self.score as usize]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Грубая оценка энтропии: размер алфавита × длина, со штрафом за повторы.
|
|
||||||
pub fn strength(pw: &str) -> Strength {
|
|
||||||
if pw.is_empty() {
|
|
||||||
return Strength { score: 0, bits: 0.0 };
|
|
||||||
}
|
|
||||||
let mut pool = 0u32;
|
|
||||||
if pw.chars().any(|c| c.is_ascii_lowercase()) {
|
|
||||||
pool += 26;
|
|
||||||
}
|
|
||||||
if pw.chars().any(|c| c.is_ascii_uppercase()) {
|
|
||||||
pool += 26;
|
|
||||||
}
|
|
||||||
if pw.chars().any(|c| c.is_ascii_digit()) {
|
|
||||||
pool += 10;
|
|
||||||
}
|
|
||||||
if pw.chars().any(|c| c.is_ascii_punctuation() || c == ' ') {
|
|
||||||
pool += 33;
|
|
||||||
}
|
|
||||||
if !pw.is_ascii() {
|
|
||||||
pool += 66; // кириллица и прочее
|
|
||||||
}
|
|
||||||
let mut unique: Vec<char> = pw.chars().collect();
|
|
||||||
unique.sort_unstable();
|
|
||||||
unique.dedup();
|
|
||||||
let len = pw.chars().count() as f64;
|
|
||||||
// Повторяющиеся символы почти не добавляют стойкости.
|
|
||||||
let effective = (unique.len() as f64).max(len * 0.5).min(len);
|
|
||||||
let bits = effective * (pool.max(1) as f64).log2();
|
|
||||||
let score = match bits {
|
|
||||||
b if b < 28.0 => 0,
|
|
||||||
b if b < 40.0 => 1,
|
|
||||||
b if b < 60.0 => 2,
|
|
||||||
b if b < 80.0 => 3,
|
|
||||||
_ => 4,
|
|
||||||
};
|
|
||||||
Strength { score, bits }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn respects_options() {
|
|
||||||
let o = GenOptions { length: 32, symbols: false, ..Default::default() };
|
|
||||||
for _ in 0..50 {
|
|
||||||
let p = generate(&o);
|
|
||||||
assert_eq!(p.chars().count(), 32);
|
|
||||||
assert!(p.chars().all(|c| c.is_ascii_alphanumeric()));
|
|
||||||
assert!(p.chars().any(|c| c.is_ascii_digit()));
|
|
||||||
assert!(!p.chars().any(|c| AMBIGUOUS.contains(c)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strength_ordering() {
|
|
||||||
assert!(strength("123456").score < strength("Tr0ub4dor&3xK!9qZ").score);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
184
src/icons.rs
184
src/icons.rs
@ -1,184 +0,0 @@
|
|||||||
//! Векторные иконки: сетка 16×16, одна толщина линии, без шрифтовых глифов.
|
|
||||||
|
|
||||||
use std::f32::consts::{PI, TAU};
|
|
||||||
|
|
||||||
use eframe::egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
||||||
pub enum Icon {
|
|
||||||
Copy,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
External,
|
|
||||||
Trash,
|
|
||||||
Star,
|
|
||||||
StarFilled,
|
|
||||||
Edit,
|
|
||||||
Lock,
|
|
||||||
Gear,
|
|
||||||
Sync,
|
|
||||||
Dice,
|
|
||||||
Plus,
|
|
||||||
Folder,
|
|
||||||
List,
|
|
||||||
Search,
|
|
||||||
Close,
|
|
||||||
Warning,
|
|
||||||
Key,
|
|
||||||
File,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Нарисовать иконку в квадрате `rect` (обычно 16×16).
|
|
||||||
pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
|
||||||
let s = rect.width() / 16.0;
|
|
||||||
let o = rect.min;
|
|
||||||
let at = |x: f32, y: f32| pos2(o.x + x * s, o.y + y * s);
|
|
||||||
let st = Stroke::new((1.35 * s).max(1.1), color);
|
|
||||||
let line = |pts: &[(f32, f32)]| {
|
|
||||||
p.add(Shape::line(pts.iter().map(|&(x, y)| at(x, y)).collect(), st));
|
|
||||||
};
|
|
||||||
let closed = |pts: &[(f32, f32)]| {
|
|
||||||
p.add(Shape::closed_line(pts.iter().map(|&(x, y)| at(x, y)).collect(), st));
|
|
||||||
};
|
|
||||||
let arc = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| -> Vec<Pos2> {
|
|
||||||
let n = 20;
|
|
||||||
(0..=n)
|
|
||||||
.map(|i| {
|
|
||||||
let a = a0 + (a1 - a0) * i as f32 / n as f32;
|
|
||||||
at(cx + r * a.cos(), cy + r * a.sin())
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
match icon {
|
|
||||||
Icon::Copy => {
|
|
||||||
line(&[(5.5, 5.0), (5.5, 2.5), (13.5, 2.5), (13.5, 10.5), (11.0, 10.5)]);
|
|
||||||
closed(&[(2.5, 5.5), (10.5, 5.5), (10.5, 13.5), (2.5, 13.5)]);
|
|
||||||
}
|
|
||||||
Icon::Eye | Icon::EyeOff => {
|
|
||||||
let mut top = arc(8.0, 13.0, 8.2, -PI + 0.66, -0.66);
|
|
||||||
let bottom = arc(8.0, 3.0, 8.2, 0.66, PI - 0.66);
|
|
||||||
top.extend(bottom);
|
|
||||||
p.add(Shape::closed_line(top, st));
|
|
||||||
p.circle_stroke(at(8.0, 8.0), 2.1 * s, st);
|
|
||||||
if icon == Icon::EyeOff {
|
|
||||||
line(&[(2.5, 13.5), (13.5, 2.5)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon::External => {
|
|
||||||
line(&[(7.0, 3.0), (3.0, 3.0), (3.0, 13.0), (13.0, 13.0), (13.0, 9.0)]);
|
|
||||||
line(&[(9.5, 2.5), (13.5, 2.5), (13.5, 6.5)]);
|
|
||||||
line(&[(13.3, 2.7), (7.5, 8.5)]);
|
|
||||||
}
|
|
||||||
Icon::Trash => {
|
|
||||||
line(&[(2.5, 4.5), (13.5, 4.5)]);
|
|
||||||
line(&[(6.0, 4.5), (6.0, 2.5), (10.0, 2.5), (10.0, 4.5)]);
|
|
||||||
line(&[(4.0, 4.5), (4.8, 13.5), (11.2, 13.5), (12.0, 4.5)]);
|
|
||||||
line(&[(6.8, 7.0), (6.8, 11.0)]);
|
|
||||||
line(&[(9.2, 7.0), (9.2, 11.0)]);
|
|
||||||
}
|
|
||||||
Icon::Star | Icon::StarFilled => {
|
|
||||||
let pts: Vec<Pos2> = (0..10)
|
|
||||||
.map(|i| {
|
|
||||||
let r = if i % 2 == 0 { 6.3 } else { 2.7 };
|
|
||||||
let a = -PI / 2.0 + i as f32 * TAU / 10.0;
|
|
||||||
at(8.0 + r * a.cos(), 8.6 + r * a.sin())
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if icon == Icon::StarFilled {
|
|
||||||
let mut mesh = Mesh::default();
|
|
||||||
mesh.colored_vertex(at(8.0, 8.6), color);
|
|
||||||
for &q in &pts {
|
|
||||||
mesh.colored_vertex(q, color);
|
|
||||||
}
|
|
||||||
for i in 0..10u32 {
|
|
||||||
mesh.add_triangle(0, 1 + i, 1 + (i + 1) % 10);
|
|
||||||
}
|
|
||||||
p.add(Shape::mesh(mesh));
|
|
||||||
}
|
|
||||||
p.add(Shape::closed_line(pts, st));
|
|
||||||
}
|
|
||||||
Icon::Edit => {
|
|
||||||
closed(&[(3.0, 13.0), (3.0, 10.5), (10.5, 3.0), (13.0, 5.5), (5.5, 13.0)]);
|
|
||||||
line(&[(8.8, 4.7), (11.3, 7.2)]);
|
|
||||||
}
|
|
||||||
Icon::Lock => {
|
|
||||||
let mut sh = vec![at(5.0, 7.5)];
|
|
||||||
sh.extend(arc(8.0, 5.5, 3.0, PI, TAU));
|
|
||||||
sh.push(at(11.0, 7.5));
|
|
||||||
p.add(Shape::line(sh, st));
|
|
||||||
p.rect_stroke(
|
|
||||||
Rect::from_min_max(at(3.0, 7.5), at(13.0, 14.0)),
|
|
||||||
1.5 * s,
|
|
||||||
st,
|
|
||||||
eframe::egui::StrokeKind::Middle,
|
|
||||||
);
|
|
||||||
line(&[(8.0, 10.0), (8.0, 11.6)]);
|
|
||||||
}
|
|
||||||
Icon::Gear => {
|
|
||||||
p.circle_stroke(at(8.0, 8.0), 2.2 * s, st);
|
|
||||||
p.circle_stroke(at(8.0, 8.0), 4.6 * s, st);
|
|
||||||
for i in 0..8 {
|
|
||||||
let a = i as f32 * TAU / 8.0;
|
|
||||||
let (c, sn) = (a.cos(), a.sin());
|
|
||||||
p.line_segment(
|
|
||||||
[at(8.0 + 4.6 * c, 8.0 + 4.6 * sn), at(8.0 + 6.4 * c, 8.0 + 6.4 * sn)],
|
|
||||||
Stroke::new(st.width * 1.6, color),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon::Sync => {
|
|
||||||
p.add(Shape::line(arc(8.0, 8.0, 5.2, PI * 1.1, PI * 1.9), st));
|
|
||||||
line(&[(10.8, 2.6), (12.9, 4.9), (10.4, 5.9)]);
|
|
||||||
p.add(Shape::line(arc(8.0, 8.0, 5.2, PI * 0.1, PI * 0.9), st));
|
|
||||||
line(&[(5.2, 13.4), (3.1, 11.1), (5.6, 10.1)]);
|
|
||||||
}
|
|
||||||
Icon::Dice => {
|
|
||||||
p.rect_stroke(
|
|
||||||
Rect::from_min_max(at(2.5, 2.5), at(13.5, 13.5)),
|
|
||||||
2.5 * s,
|
|
||||||
st,
|
|
||||||
eframe::egui::StrokeKind::Middle,
|
|
||||||
);
|
|
||||||
for (x, y) in [(5.5, 5.5), (8.0, 8.0), (10.5, 10.5)] {
|
|
||||||
p.circle_filled(at(x, y), 1.1 * s, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon::Plus => {
|
|
||||||
line(&[(8.0, 3.0), (8.0, 13.0)]);
|
|
||||||
line(&[(3.0, 8.0), (13.0, 8.0)]);
|
|
||||||
}
|
|
||||||
Icon::Folder => {
|
|
||||||
closed(&[(2.0, 3.5), (6.3, 3.5), (7.8, 5.2), (14.0, 5.2), (14.0, 13.0), (2.0, 13.0)]);
|
|
||||||
}
|
|
||||||
Icon::List => {
|
|
||||||
for y in [4.0, 8.0, 12.0] {
|
|
||||||
line(&[(5.5, y), (13.5, y)]);
|
|
||||||
p.circle_filled(at(2.8, y), 0.95 * s, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon::Search => {
|
|
||||||
p.circle_stroke(at(7.0, 7.0), 4.3 * s, st);
|
|
||||||
line(&[(10.2, 10.2), (13.5, 13.5)]);
|
|
||||||
}
|
|
||||||
Icon::Close => {
|
|
||||||
line(&[(4.0, 4.0), (12.0, 12.0)]);
|
|
||||||
line(&[(12.0, 4.0), (4.0, 12.0)]);
|
|
||||||
}
|
|
||||||
Icon::Warning => {
|
|
||||||
closed(&[(8.0, 2.2), (14.5, 13.5), (1.5, 13.5)]);
|
|
||||||
line(&[(8.0, 6.3), (8.0, 9.6)]);
|
|
||||||
p.circle_filled(at(8.0, 11.6), 0.9 * s, color);
|
|
||||||
}
|
|
||||||
Icon::Key => {
|
|
||||||
p.circle_stroke(at(5.2, 10.8), 2.9 * s, st);
|
|
||||||
line(&[(7.3, 8.7), (13.5, 2.5)]);
|
|
||||||
line(&[(11.2, 4.8), (12.9, 6.5)]);
|
|
||||||
line(&[(9.6, 6.4), (10.9, 7.7)]);
|
|
||||||
}
|
|
||||||
Icon::File => {
|
|
||||||
closed(&[(3.5, 1.8), (9.5, 1.8), (12.5, 4.8), (12.5, 14.2), (3.5, 14.2)]);
|
|
||||||
line(&[(9.3, 2.0), (9.3, 5.0), (12.3, 5.0)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
src/main.rs
33
src/main.rs
@ -1,33 +0,0 @@
|
|||||||
// В релизной сборке на Windows не показывать консольное окно.
|
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
mod app;
|
|
||||||
mod config;
|
|
||||||
mod crypto;
|
|
||||||
#[cfg(feature = "dev-shots")]
|
|
||||||
mod devshots;
|
|
||||||
mod generator;
|
|
||||||
mod icons;
|
|
||||||
mod storage;
|
|
||||||
mod theme;
|
|
||||||
mod totp;
|
|
||||||
mod vault;
|
|
||||||
mod widgets;
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
|
||||||
// Путь к хранилищу можно передать аргументом (ассоциация файлов .qulyp).
|
|
||||||
let open = std::env::args_os().nth(1).map(std::path::PathBuf::from).filter(|p| p.is_file());
|
|
||||||
|
|
||||||
let options = eframe::NativeOptions {
|
|
||||||
viewport: egui::ViewportBuilder::default()
|
|
||||||
.with_title("Qulyp")
|
|
||||||
.with_app_id("qulyp")
|
|
||||||
.with_inner_size([1140.0, 740.0])
|
|
||||||
.with_min_inner_size([860.0, 540.0])
|
|
||||||
.with_icon(theme::app_icon()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
eframe::run_native("Qulyp", options, Box::new(|cc| Ok(Box::new(app::QulypApp::new(cc, open)))))
|
|
||||||
}
|
|
||||||
304
src/storage.rs
304
src/storage.rs
@ -1,304 +0,0 @@
|
|||||||
//! Открытое хранилище: чтение/запись файла и синхронизация.
|
|
||||||
//!
|
|
||||||
//! Синхронизация рассчитана на «папку в облаке» (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<SystemTime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stamp(path: &Path) -> Option<Stamp> {
|
|
||||||
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<Vault> {
|
|
||||||
serde_json::from_slice(plain).context("Не удалось прочитать содержимое хранилища")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize(vault: &Vault, key: &VaultKey) -> Result<Vec<u8>> {
|
|
||||||
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<u8>)> {
|
|
||||||
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<Stamp>,
|
|
||||||
disk_hash: [u8; 32],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Session {
|
|
||||||
pub fn open(path: PathBuf, password: &str) -> Result<Self> {
|
|
||||||
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<Self> {
|
|
||||||
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<Option<Vec<u8>>> {
|
|
||||||
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<Vault> {
|
|
||||||
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<MergeReport> {
|
|
||||||
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<Option<MergeReport>> {
|
|
||||||
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<MergeReport> {
|
|
||||||
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<Vault> {
|
|
||||||
let bytes = fs::read(path).with_context(|| format!("Не удалось открыть {}", path.display()))?;
|
|
||||||
self.decrypt_foreign(&bytes).map_err(|e| {
|
|
||||||
if e.is::<ForeignKey>() {
|
|
||||||
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::<ForeignKey>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
247
src/theme.rs
247
src/theme.rs
@ -1,247 +0,0 @@
|
|||||||
//! Цвета, стиль виджетов, шрифты и иконка окна.
|
|
||||||
//!
|
|
||||||
//! Строгая графитовая палитра: нейтрали делают основную работу, единственный
|
|
||||||
//! приглушённый стальной акцент — только для главного действия, выделения и фокуса.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use eframe::egui::{
|
|
||||||
self, Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, IconData, Margin, Shadow, Stroke,
|
|
||||||
Style, TextStyle, Theme, Visuals, vec2,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Радиусы скругления: контролы, карточки/панели, окна.
|
|
||||||
pub const R_CTRL: u8 = 4;
|
|
||||||
pub const R_CARD: u8 = 6;
|
|
||||||
pub const R_WIN: u8 = 8;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub struct Pal {
|
|
||||||
pub bg: Color32,
|
|
||||||
pub sidebar: Color32,
|
|
||||||
pub list: Color32,
|
|
||||||
pub card: Color32,
|
|
||||||
pub field: Color32,
|
|
||||||
/// Разделители и рамки панелей.
|
|
||||||
pub border: Color32,
|
|
||||||
/// Рамки интерактивных контролов (контраст ≥ 3:1 к фону).
|
|
||||||
pub control: Color32,
|
|
||||||
pub text: Color32,
|
|
||||||
pub weak: Color32,
|
|
||||||
/// Заливка главной кнопки (под белый текст).
|
|
||||||
pub accent: Color32,
|
|
||||||
/// Акцент как цвет текста/иконки на фоне.
|
|
||||||
pub accent_fg: Color32,
|
|
||||||
pub accent_soft: Color32,
|
|
||||||
pub hover: Color32,
|
|
||||||
pub danger: Color32,
|
|
||||||
pub success: Color32,
|
|
||||||
pub warn: Color32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const ACCENT: Color32 = Color32::from_rgb(0x44, 0x6F, 0xB0);
|
|
||||||
|
|
||||||
pub fn pal(dark: bool) -> Pal {
|
|
||||||
if dark {
|
|
||||||
Pal {
|
|
||||||
bg: Color32::from_rgb(0x11, 0x12, 0x14),
|
|
||||||
sidebar: Color32::from_rgb(0x0D, 0x0E, 0x10),
|
|
||||||
list: Color32::from_rgb(0x14, 0x15, 0x18),
|
|
||||||
card: Color32::from_rgb(0x17, 0x19, 0x1C),
|
|
||||||
field: Color32::from_rgb(0x1B, 0x1D, 0x21),
|
|
||||||
border: Color32::from_rgb(0x25, 0x28, 0x2D),
|
|
||||||
control: Color32::from_rgb(0x3A, 0x3E, 0x45),
|
|
||||||
text: Color32::from_rgb(0xE4, 0xE6, 0xE9),
|
|
||||||
weak: Color32::from_rgb(0x8C, 0x92, 0x9A),
|
|
||||||
accent: ACCENT,
|
|
||||||
accent_fg: Color32::from_rgb(0x8F, 0xAE, 0xDB),
|
|
||||||
accent_soft: Color32::from_rgba_unmultiplied(0x5B, 0x84, 0xC4, 34),
|
|
||||||
hover: Color32::from_rgb(0x1D, 0x1F, 0x23),
|
|
||||||
danger: Color32::from_rgb(0xD2, 0x6A, 0x6F),
|
|
||||||
success: Color32::from_rgb(0x6B, 0xB0, 0x8A),
|
|
||||||
warn: Color32::from_rgb(0xC8, 0xA5, 0x5A),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Pal {
|
|
||||||
bg: Color32::from_rgb(0xF4, 0xF5, 0xF6),
|
|
||||||
sidebar: Color32::from_rgb(0xEB, 0xEC, 0xEE),
|
|
||||||
list: Color32::from_rgb(0xF9, 0xF9, 0xFA),
|
|
||||||
card: Color32::WHITE,
|
|
||||||
field: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
|
||||||
border: Color32::from_rgb(0xDD, 0xE0, 0xE4),
|
|
||||||
control: Color32::from_rgb(0xB9, 0xBE, 0xC6),
|
|
||||||
text: Color32::from_rgb(0x16, 0x18, 0x1B),
|
|
||||||
weak: Color32::from_rgb(0x5C, 0x62, 0x6A),
|
|
||||||
accent: Color32::from_rgb(0x2F, 0x5C, 0xA0),
|
|
||||||
accent_fg: Color32::from_rgb(0x2F, 0x5C, 0xA0),
|
|
||||||
accent_soft: Color32::from_rgba_unmultiplied(0x2F, 0x5C, 0xA0, 22),
|
|
||||||
hover: Color32::from_rgb(0xE6, 0xE8, 0xEB),
|
|
||||||
danger: Color32::from_rgb(0xB0, 0x3E, 0x46),
|
|
||||||
success: Color32::from_rgb(0x2E, 0x7D, 0x55),
|
|
||||||
warn: Color32::from_rgb(0x8C, 0x68, 0x14),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bold() -> FontFamily {
|
|
||||||
FontFamily::Name("bold".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visuals(dark: bool) -> Visuals {
|
|
||||||
let p = pal(dark);
|
|
||||||
let mut v = if dark { Visuals::dark() } else { Visuals::light() };
|
|
||||||
v.panel_fill = p.bg;
|
|
||||||
v.window_fill = p.card;
|
|
||||||
v.faint_bg_color = p.list;
|
|
||||||
v.extreme_bg_color = p.field;
|
|
||||||
v.text_edit_bg_color = Some(p.field);
|
|
||||||
v.code_bg_color = p.field;
|
|
||||||
v.window_stroke = Stroke::new(1.0, p.border);
|
|
||||||
v.window_corner_radius = CornerRadius::same(R_WIN);
|
|
||||||
v.menu_corner_radius = CornerRadius::same(R_CARD);
|
|
||||||
let a = if dark { 90 } else { 26 };
|
|
||||||
v.window_shadow = Shadow { offset: [0, 6], blur: 20, spread: 0, color: Color32::from_black_alpha(a) };
|
|
||||||
v.popup_shadow = Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(a) };
|
|
||||||
v.hyperlink_color = p.accent_fg;
|
|
||||||
v.selection.bg_fill = p.accent.gamma_multiply(if dark { 0.55 } else { 0.25 });
|
|
||||||
v.selection.stroke = Stroke::new(1.0, p.accent_fg);
|
|
||||||
v.error_fg_color = p.danger;
|
|
||||||
v.warn_fg_color = p.warn;
|
|
||||||
v.slider_trailing_fill = true;
|
|
||||||
v.handle_shape = egui::style::HandleShape::Rect { aspect_ratio: 0.5 };
|
|
||||||
v.striped = false;
|
|
||||||
|
|
||||||
let r = CornerRadius::same(R_CTRL);
|
|
||||||
let w = &mut v.widgets;
|
|
||||||
for s in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] {
|
|
||||||
s.corner_radius = r;
|
|
||||||
s.expansion = 0.0;
|
|
||||||
}
|
|
||||||
w.noninteractive.bg_stroke = Stroke::new(1.0, p.border);
|
|
||||||
w.noninteractive.fg_stroke = Stroke::new(1.0, p.text);
|
|
||||||
w.inactive.weak_bg_fill = p.field;
|
|
||||||
w.inactive.bg_fill = p.field;
|
|
||||||
w.inactive.bg_stroke = Stroke::new(1.0, p.control);
|
|
||||||
w.inactive.fg_stroke = Stroke::new(1.0, p.text);
|
|
||||||
w.hovered.weak_bg_fill = p.hover;
|
|
||||||
w.hovered.bg_fill = p.hover;
|
|
||||||
w.hovered.bg_stroke = Stroke::new(1.0, p.weak);
|
|
||||||
w.hovered.fg_stroke = Stroke::new(1.0, p.text);
|
|
||||||
w.active.weak_bg_fill = p.hover;
|
|
||||||
w.active.bg_fill = p.hover;
|
|
||||||
w.active.bg_stroke = Stroke::new(1.0, p.accent_fg);
|
|
||||||
w.active.fg_stroke = Stroke::new(1.0, p.text);
|
|
||||||
w.open.weak_bg_fill = p.hover;
|
|
||||||
w.open.bg_stroke = Stroke::new(1.0, p.control);
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn install(ctx: &egui::Context) {
|
|
||||||
install_fonts(ctx);
|
|
||||||
for (theme, dark) in [(Theme::Dark, true), (Theme::Light, false)] {
|
|
||||||
let mut style = Style { visuals: visuals(dark), ..Default::default() };
|
|
||||||
style.spacing.item_spacing = vec2(8.0, 8.0);
|
|
||||||
style.spacing.button_padding = vec2(12.0, 5.0);
|
|
||||||
style.spacing.interact_size.y = 30.0;
|
|
||||||
style.spacing.window_margin = Margin::same(20);
|
|
||||||
style.spacing.menu_margin = Margin::same(6);
|
|
||||||
style.spacing.scroll.bar_width = 6.0;
|
|
||||||
style.spacing.scroll.floating = true;
|
|
||||||
style.text_styles = [
|
|
||||||
(TextStyle::Small, FontId::proportional(12.0)),
|
|
||||||
(TextStyle::Body, FontId::proportional(14.0)),
|
|
||||||
(TextStyle::Button, FontId::proportional(14.0)),
|
|
||||||
(TextStyle::Heading, FontId::new(20.0, bold())),
|
|
||||||
(TextStyle::Monospace, FontId::monospace(14.0)),
|
|
||||||
]
|
|
||||||
.into();
|
|
||||||
ctx.set_style_of(theme, style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Системные шрифты с кириллицей, если есть; иначе — встроенные в egui.
|
|
||||||
fn install_fonts(ctx: &egui::Context) {
|
|
||||||
const REGULAR: &[&str] = &[
|
|
||||||
"C:\\Windows\\Fonts\\segoeui.ttf",
|
|
||||||
"/usr/share/fonts/truetype/inter/Inter-Regular.ttf",
|
|
||||||
"/usr/share/fonts/inter/Inter-Regular.otf",
|
|
||||||
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
|
||||||
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
|
|
||||||
"/usr/share/fonts/google-noto/NotoSans-Regular.ttf",
|
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
||||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
||||||
];
|
|
||||||
const BOLD: &[&str] = &[
|
|
||||||
"C:\\Windows\\Fonts\\seguisb.ttf",
|
|
||||||
"/usr/share/fonts/truetype/inter/Inter-SemiBold.ttf",
|
|
||||||
"/usr/share/fonts/inter/Inter-SemiBold.otf",
|
|
||||||
"/usr/share/fonts/truetype/noto/NotoSans-SemiBold.ttf",
|
|
||||||
"/usr/share/fonts/noto/NotoSans-SemiBold.ttf",
|
|
||||||
"/usr/share/fonts/google-noto/NotoSans-SemiBold.ttf",
|
|
||||||
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
|
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
||||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
||||||
];
|
|
||||||
const MONO: &[&str] = &[
|
|
||||||
"C:\\Windows\\Fonts\\consola.ttf",
|
|
||||||
"/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf",
|
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
|
||||||
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
|
|
||||||
];
|
|
||||||
let first = |list: &[&str]| list.iter().find_map(|p| std::fs::read(p).ok());
|
|
||||||
|
|
||||||
let mut fonts = FontDefinitions::default();
|
|
||||||
|
|
||||||
if let Some(b) = first(REGULAR) {
|
|
||||||
fonts.font_data.insert("ui".into(), Arc::new(FontData::from_owned(b)));
|
|
||||||
fonts.families.get_mut(&FontFamily::Proportional).unwrap().insert(0, "ui".into());
|
|
||||||
}
|
|
||||||
let mut bold_family = fonts.families[&FontFamily::Proportional].clone();
|
|
||||||
if let Some(b) = first(BOLD) {
|
|
||||||
fonts.font_data.insert("ui-bold".into(), Arc::new(FontData::from_owned(b)));
|
|
||||||
bold_family.insert(0, "ui-bold".into());
|
|
||||||
}
|
|
||||||
fonts.families.insert(bold(), bold_family);
|
|
||||||
if let Some(b) = first(MONO) {
|
|
||||||
fonts.font_data.insert("mono".into(), Arc::new(FontData::from_owned(b)));
|
|
||||||
let mono = fonts.families.get_mut(&FontFamily::Monospace).unwrap();
|
|
||||||
mono.insert(0, "mono".into());
|
|
||||||
}
|
|
||||||
ctx.set_fonts(fonts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Иконка окна: скруглённый квадрат с замком, рисуется попиксельно.
|
|
||||||
pub fn app_icon() -> IconData {
|
|
||||||
const N: usize = 64;
|
|
||||||
let mut rgba = vec![0u8; N * N * 4];
|
|
||||||
let inside_rrect = |x: f32, y: f32, x0: f32, y0: f32, x1: f32, y1: f32, r: f32| {
|
|
||||||
let cx = x.clamp(x0 + r, x1 - r);
|
|
||||||
let cy = y.clamp(y0 + r, y1 - r);
|
|
||||||
(x - cx).powi(2) + (y - cy).powi(2) <= r * r
|
|
||||||
};
|
|
||||||
for py in 0..N {
|
|
||||||
for px in 0..N {
|
|
||||||
let (x, y) = (px as f32 + 0.5, py as f32 + 0.5);
|
|
||||||
let i = (py * N + px) * 4;
|
|
||||||
if !inside_rrect(x, y, 2.0, 2.0, 62.0, 62.0, 10.0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let [r, g, b, _] = ACCENT.to_array();
|
|
||||||
let mut c = [r, g, b, 255];
|
|
||||||
// Дужка замка
|
|
||||||
let d = ((x - 32.0).powi(2) + (y - 27.0).powi(2)).sqrt();
|
|
||||||
let shackle = y <= 30.0 && (8.5..=13.0).contains(&d);
|
|
||||||
let legs = (19.0..=23.5).contains(&x) || (40.5..=45.0).contains(&x);
|
|
||||||
let shackle = shackle || (legs && (27.0..=32.0).contains(&y));
|
|
||||||
// Корпус с замочной скважиной
|
|
||||||
let body = inside_rrect(x, y, 15.0, 30.0, 49.0, 54.0, 5.0);
|
|
||||||
let hole = ((x - 32.0).powi(2) + (y - 39.5).powi(2)).sqrt() <= 3.5
|
|
||||||
|| ((30.5..=33.5).contains(&x) && (39.5..=47.0).contains(&y));
|
|
||||||
if (shackle || body) && !hole {
|
|
||||||
c = [255, 255, 255, 255];
|
|
||||||
}
|
|
||||||
rgba[i..i + 4].copy_from_slice(&c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IconData { rgba, width: N as u32, height: N as u32 }
|
|
||||||
}
|
|
||||||
145
src/totp.rs
145
src/totp.rs
@ -1,145 +0,0 @@
|
|||||||
//! Одноразовые коды 2FA (TOTP, RFC 6238).
|
|
||||||
|
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum Algo {
|
|
||||||
Sha1,
|
|
||||||
Sha256,
|
|
||||||
Sha512,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Totp {
|
|
||||||
secret: Vec<u8>,
|
|
||||||
digits: u32,
|
|
||||||
period: u64,
|
|
||||||
algo: Algo,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Totp {
|
|
||||||
/// Принимает base32-секрет или ссылку `otpauth://totp/...?secret=...`.
|
|
||||||
pub fn parse(input: &str) -> Option<Self> {
|
|
||||||
let input = input.trim();
|
|
||||||
if input.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut t = Totp { secret: Vec::new(), digits: 6, period: 30, algo: Algo::Sha1 };
|
|
||||||
if input.to_ascii_lowercase().starts_with("otpauth://") {
|
|
||||||
let query = input.split_once('?')?.1;
|
|
||||||
for pair in query.split('&') {
|
|
||||||
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
|
|
||||||
let v = percent_decode(v);
|
|
||||||
match k.to_ascii_lowercase().as_str() {
|
|
||||||
"secret" => t.secret = base32_decode(&v)?,
|
|
||||||
"digits" => t.digits = v.parse().ok().filter(|d| (6..=8).contains(d))?,
|
|
||||||
"period" => t.period = v.parse().ok().filter(|p| *p > 0)?,
|
|
||||||
"algorithm" => {
|
|
||||||
t.algo = match v.to_ascii_uppercase().as_str() {
|
|
||||||
"SHA256" => Algo::Sha256,
|
|
||||||
"SHA512" => Algo::Sha512,
|
|
||||||
_ => Algo::Sha1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.secret = base32_decode(input)?;
|
|
||||||
}
|
|
||||||
(!t.secret.is_empty()).then_some(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn code_at(&self, unix: u64) -> String {
|
|
||||||
let counter = (unix / self.period).to_be_bytes();
|
|
||||||
let digest = match self.algo {
|
|
||||||
Algo::Sha1 => hmac_sha1(&self.secret, &counter),
|
|
||||||
Algo::Sha256 => hmac_sha256(&self.secret, &counter),
|
|
||||||
Algo::Sha512 => hmac_sha512(&self.secret, &counter),
|
|
||||||
};
|
|
||||||
let off = (digest[digest.len() - 1] & 0x0f) as usize;
|
|
||||||
let bin = u32::from_be_bytes([digest[off] & 0x7f, digest[off + 1], digest[off + 2], digest[off + 3]]);
|
|
||||||
let code = bin % 10u32.pow(self.digits);
|
|
||||||
format!("{code:0width$}", width = self.digits as usize)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn now(&self) -> (String, u64) {
|
|
||||||
let unix = chrono::Utc::now().timestamp().max(0) as u64;
|
|
||||||
(self.code_at(unix), self.period - unix % self.period)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn period(&self) -> u64 {
|
|
||||||
self.period
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! hmac_fn {
|
|
||||||
($name:ident, $d:ty) => {
|
|
||||||
fn $name(key: &[u8], msg: &[u8]) -> Vec<u8> {
|
|
||||||
let mut mac = Hmac::<$d>::new_from_slice(key).expect("HMAC принимает ключ любой длины");
|
|
||||||
mac.update(msg);
|
|
||||||
mac.finalize().into_bytes().to_vec()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
hmac_fn!(hmac_sha1, sha1::Sha1);
|
|
||||||
hmac_fn!(hmac_sha256, sha2::Sha256);
|
|
||||||
hmac_fn!(hmac_sha512, sha2::Sha512);
|
|
||||||
|
|
||||||
fn base32_decode(s: &str) -> Option<Vec<u8>> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let (mut buf, mut bits) = (0u32, 0u32);
|
|
||||||
for c in s.chars().filter(|c| !c.is_whitespace() && *c != '=' && *c != '-') {
|
|
||||||
let v = match c.to_ascii_uppercase() {
|
|
||||||
c @ 'A'..='Z' => c as u32 - 'A' as u32,
|
|
||||||
c @ '2'..='7' => c as u32 - '2' as u32 + 26,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
buf = (buf << 5) | v;
|
|
||||||
bits += 5;
|
|
||||||
if bits >= 8 {
|
|
||||||
bits -= 8;
|
|
||||||
out.push((buf >> bits) as u8);
|
|
||||||
buf &= (1 << bits) - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percent_decode(s: &str) -> String {
|
|
||||||
let b = s.as_bytes();
|
|
||||||
let mut out = Vec::with_capacity(b.len());
|
|
||||||
let mut i = 0;
|
|
||||||
while i < b.len() {
|
|
||||||
if b[i] == b'%' && i + 2 < b.len() {
|
|
||||||
let hex = std::str::from_utf8(&b[i + 1..i + 3]).ok();
|
|
||||||
if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
|
||||||
out.push(v);
|
|
||||||
i += 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push(if b[i] == b'+' { b' ' } else { b[i] });
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
String::from_utf8_lossy(&out).into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rfc6238_sha1_vector() {
|
|
||||||
// Секрет "12345678901234567890" в base32.
|
|
||||||
let t = Totp::parse("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ").unwrap();
|
|
||||||
let t8 = Totp { digits: 8, ..t };
|
|
||||||
assert_eq!(t8.code_at(59), "94287082");
|
|
||||||
assert_eq!(t8.code_at(1111111109), "07081804");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn otpauth_uri() {
|
|
||||||
let t = Totp::parse("otpauth://totp/X:me?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=X&digits=6").unwrap();
|
|
||||||
assert_eq!(t.code_at(59), "287082");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
349
src/vault.rs
349
src/vault.rs
@ -1,349 +0,0 @@
|
|||||||
//! Модель данных хранилища и слияние двух версий (синхронизация).
|
|
||||||
//!
|
|
||||||
//! Слияние: у каждой записи и папки есть `id` и `modified`. Для одинаковых `id`
|
|
||||||
//! побеждает более свежая версия, пароль проигравшей уходит в историю.
|
|
||||||
//! Удаления хранятся как «надгробия» (`tombstones`), чтобы удалённая на одном
|
|
||||||
//! устройстве запись не воскресла после синхронизации с другим.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
use zeroize::Zeroize;
|
|
||||||
|
|
||||||
const HISTORY_LIMIT: usize = 20;
|
|
||||||
|
|
||||||
pub fn now_ms() -> i64 {
|
|
||||||
chrono::Utc::now().timestamp_millis()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Default)]
|
|
||||||
pub struct OldPassword {
|
|
||||||
pub password: String,
|
|
||||||
/// Когда этот пароль перестал быть актуальным (мс).
|
|
||||||
pub changed: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for OldPassword {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.password.zeroize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, PartialEq)]
|
|
||||||
pub struct Entry {
|
|
||||||
#[serde(default)]
|
|
||||||
pub id: Uuid,
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub username: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub password: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub url: String,
|
|
||||||
/// TOTP: base32-секрет или ссылка `otpauth://`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub totp: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub notes: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub group: Option<Uuid>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub favorite: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub created: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub modified: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub history: Vec<OldPassword>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Entry {
|
|
||||||
fn default() -> Self {
|
|
||||||
let now = now_ms();
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
title: String::new(),
|
|
||||||
username: String::new(),
|
|
||||||
password: String::new(),
|
|
||||||
url: String::new(),
|
|
||||||
totp: String::new(),
|
|
||||||
notes: String::new(),
|
|
||||||
group: None,
|
|
||||||
favorite: false,
|
|
||||||
created: now,
|
|
||||||
modified: now,
|
|
||||||
history: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Entry {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.password.zeroize();
|
|
||||||
self.totp.zeroize();
|
|
||||||
self.notes.zeroize();
|
|
||||||
self.username.zeroize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Entry {
|
|
||||||
pub fn matches(&self, query: &str) -> bool {
|
|
||||||
if query.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
[&self.title, &self.username, &self.url, &self.notes].iter().any(|f| f.to_lowercase().contains(query))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Добавить пароли `other` (текущий и историю) в историю `self`, без дублей.
|
|
||||||
fn absorb_history(&mut self, other: &Entry) {
|
|
||||||
let add = |pw: &str, changed: i64, this: &mut Entry| {
|
|
||||||
if pw.is_empty() || pw == this.password || this.history.iter().any(|h| h.password == pw) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.history.push(OldPassword { password: pw.to_owned(), changed });
|
|
||||||
};
|
|
||||||
add(&other.password, other.modified, self);
|
|
||||||
for h in &other.history {
|
|
||||||
add(&h.password, h.changed, self);
|
|
||||||
}
|
|
||||||
self.history.sort_by(|a, b| b.changed.cmp(&a.changed));
|
|
||||||
self.history.truncate(HISTORY_LIMIT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Default)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct Group {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
pub modified: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, PartialEq)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct Vault {
|
|
||||||
pub format: u32,
|
|
||||||
pub name: String,
|
|
||||||
pub created: i64,
|
|
||||||
pub entries: Vec<Entry>,
|
|
||||||
pub groups: Vec<Group>,
|
|
||||||
/// id удалённого объекта → время удаления (мс).
|
|
||||||
pub tombstones: HashMap<Uuid, i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Vault {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
format: 1,
|
|
||||||
name: String::new(),
|
|
||||||
created: now_ms(),
|
|
||||||
entries: Vec::new(),
|
|
||||||
groups: Vec::new(),
|
|
||||||
tombstones: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Copy)]
|
|
||||||
pub struct MergeReport {
|
|
||||||
pub added: usize,
|
|
||||||
pub updated: usize,
|
|
||||||
pub removed: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MergeReport {
|
|
||||||
pub fn any(&self) -> bool {
|
|
||||||
self.added + self.updated + self.removed > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Vault {
|
|
||||||
pub fn new(name: &str) -> Self {
|
|
||||||
Self { name: name.to_owned(), ..Default::default() }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn entry(&self, id: Uuid) -> Option<&Entry> {
|
|
||||||
self.entries.iter().find(|e| e.id == id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn group_name(&self, id: Option<Uuid>) -> Option<&str> {
|
|
||||||
let id = id?;
|
|
||||||
self.groups.iter().find(|g| g.id == id).map(|g| g.name.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Сохранить запись (новую или изменённую). Старый пароль уходит в историю.
|
|
||||||
pub fn upsert(&mut self, mut e: Entry) {
|
|
||||||
e.modified = now_ms();
|
|
||||||
if let Some(old) = self.entries.iter_mut().find(|x| x.id == e.id) {
|
|
||||||
if !old.password.is_empty() && old.password != e.password {
|
|
||||||
let pw = old.password.clone();
|
|
||||||
e.history.insert(0, OldPassword { password: pw, changed: e.modified });
|
|
||||||
e.history.truncate(HISTORY_LIMIT);
|
|
||||||
}
|
|
||||||
*old = e;
|
|
||||||
} else {
|
|
||||||
self.entries.push(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_entry(&mut self, id: Uuid) {
|
|
||||||
self.entries.retain(|e| e.id != id);
|
|
||||||
self.tombstones.insert(id, now_ms());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn toggle_favorite(&mut self, id: Uuid) {
|
|
||||||
if let Some(e) = self.entries.iter_mut().find(|e| e.id == id) {
|
|
||||||
e.favorite = !e.favorite;
|
|
||||||
e.modified = now_ms();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_group(&mut self, name: &str) -> Uuid {
|
|
||||||
let g = Group { id: Uuid::new_v4(), name: name.to_owned(), modified: now_ms() };
|
|
||||||
let id = g.id;
|
|
||||||
self.groups.push(g);
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rename_group(&mut self, id: Uuid, name: &str) {
|
|
||||||
if let Some(g) = self.groups.iter_mut().find(|g| g.id == id) {
|
|
||||||
g.name = name.to_owned();
|
|
||||||
g.modified = now_ms();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Удалить папку; её записи остаются, но без папки.
|
|
||||||
pub fn delete_group(&mut self, id: Uuid) {
|
|
||||||
let now = now_ms();
|
|
||||||
self.groups.retain(|g| g.id != id);
|
|
||||||
self.tombstones.insert(id, now);
|
|
||||||
for e in self.entries.iter_mut().filter(|e| e.group == Some(id)) {
|
|
||||||
e.group = None;
|
|
||||||
e.modified = now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Канонический порядок — чтобы одинаковые хранилища сравнивались равными.
|
|
||||||
pub fn normalize(&mut self) {
|
|
||||||
self.entries.sort_by_key(|e| e.id);
|
|
||||||
self.groups.sort_by_key(|g| g.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Влить в себя другую версию хранилища.
|
|
||||||
pub fn merge(&mut self, other: &Vault) -> MergeReport {
|
|
||||||
let mut r = MergeReport::default();
|
|
||||||
|
|
||||||
for (id, &t) in &other.tombstones {
|
|
||||||
let mine = self.tombstones.entry(*id).or_insert(t);
|
|
||||||
*mine = (*mine).max(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
for oe in &other.entries {
|
|
||||||
match self.entries.iter_mut().find(|e| e.id == oe.id) {
|
|
||||||
Some(me) if oe.modified > me.modified => {
|
|
||||||
let mut newer = oe.clone();
|
|
||||||
newer.absorb_history(me);
|
|
||||||
*me = newer;
|
|
||||||
r.updated += 1;
|
|
||||||
}
|
|
||||||
Some(me) => me.absorb_history(oe),
|
|
||||||
None if self.tombstones.get(&oe.id).is_some_and(|&t| t >= oe.modified) => {}
|
|
||||||
None => {
|
|
||||||
self.entries.push(oe.clone());
|
|
||||||
r.added += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for og in &other.groups {
|
|
||||||
match self.groups.iter_mut().find(|g| g.id == og.id) {
|
|
||||||
Some(mg) if og.modified > mg.modified => *mg = og.clone(),
|
|
||||||
Some(_) => {}
|
|
||||||
None => self.groups.push(og.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Применяем удаления: объект умирает, если удалён позже последней правки.
|
|
||||||
let tomb = &self.tombstones;
|
|
||||||
let dead = |id: &Uuid, modified: i64| tomb.get(id).is_some_and(|&t| t >= modified);
|
|
||||||
let before = self.entries.len();
|
|
||||||
self.entries.retain(|e| !dead(&e.id, e.modified));
|
|
||||||
r.removed = before - self.entries.len();
|
|
||||||
self.groups.retain(|g| !dead(&g.id, g.modified));
|
|
||||||
|
|
||||||
let groups: Vec<Uuid> = self.groups.iter().map(|g| g.id).collect();
|
|
||||||
for e in &mut self.entries {
|
|
||||||
if e.group.is_some_and(|g| !groups.contains(&g)) {
|
|
||||||
e.group = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.normalize();
|
|
||||||
r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn entry(title: &str, pw: &str, modified: i64) -> Entry {
|
|
||||||
let mut e = Entry::default();
|
|
||||||
e.title = title.into();
|
|
||||||
e.password = pw.into();
|
|
||||||
e.modified = modified;
|
|
||||||
e
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn newer_wins_and_old_password_goes_to_history() {
|
|
||||||
let e = entry("mail", "old", 100);
|
|
||||||
let mut a = Vault::new("a");
|
|
||||||
a.entries.push(e.clone());
|
|
||||||
let mut b = a.clone();
|
|
||||||
b.entries[0].password = "new".into();
|
|
||||||
b.entries[0].modified = 200;
|
|
||||||
|
|
||||||
let r = a.merge(&b);
|
|
||||||
assert_eq!(r.updated, 1);
|
|
||||||
assert_eq!(a.entries[0].password, "new");
|
|
||||||
assert_eq!(a.entries[0].history[0].password, "old");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deletion_propagates_but_later_edit_survives() {
|
|
||||||
let mut a = Vault::new("a");
|
|
||||||
a.entries.push(entry("x", "1", 100));
|
|
||||||
let id = a.entries[0].id;
|
|
||||||
|
|
||||||
let mut b = a.clone();
|
|
||||||
b.entries.clear();
|
|
||||||
b.tombstones.insert(id, 150);
|
|
||||||
|
|
||||||
let mut a2 = a.clone();
|
|
||||||
a2.merge(&b);
|
|
||||||
assert!(a2.entries.is_empty());
|
|
||||||
|
|
||||||
// Правка после удаления — запись остаётся.
|
|
||||||
a.entries[0].modified = 300;
|
|
||||||
a.merge(&b);
|
|
||||||
assert_eq!(a.entries.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_is_symmetric_for_disjoint_changes() {
|
|
||||||
let base = Vault::new("v");
|
|
||||||
let mut a = base.clone();
|
|
||||||
let mut b = base.clone();
|
|
||||||
a.entries.push(entry("a", "1", 10));
|
|
||||||
b.entries.push(entry("b", "2", 20));
|
|
||||||
let mut ab = a.clone();
|
|
||||||
ab.merge(&b);
|
|
||||||
let mut ba = b.clone();
|
|
||||||
ba.merge(&a);
|
|
||||||
assert_eq!(ab.entries.len(), 2);
|
|
||||||
assert!(ab.entries == ba.entries);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
324
src/widgets.rs
324
src/widgets.rs
@ -1,324 +0,0 @@
|
|||||||
//! Небольшие переиспользуемые виджеты в строгом стиле.
|
|
||||||
|
|
||||||
use eframe::egui::{
|
|
||||||
self, Align, Align2, Color32, CornerRadius, FontFamily, FontId, Frame, Layout, Margin, Rect, Response, RichText,
|
|
||||||
Sense, Stroke, StrokeKind, TextEdit, Ui, Vec2, pos2, vec2,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::generator;
|
|
||||||
use crate::icons::{self, Icon};
|
|
||||||
use crate::theme::{ACCENT, Pal, R_CARD, R_CTRL, bold};
|
|
||||||
|
|
||||||
pub fn frame_card(pal: &Pal) -> Frame {
|
|
||||||
Frame::new()
|
|
||||||
.fill(pal.card)
|
|
||||||
.stroke(Stroke::new(1.0, pal.border))
|
|
||||||
.corner_radius(CornerRadius::same(R_CARD))
|
|
||||||
.inner_margin(Margin::same(16))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn focus_ring(ui: &Ui, rect: Rect, resp: &Response, pal: &Pal) {
|
|
||||||
if resp.has_focus() {
|
|
||||||
ui.painter().rect_stroke(
|
|
||||||
rect.expand(1.5),
|
|
||||||
CornerRadius::same(R_CTRL + 1),
|
|
||||||
Stroke::new(1.5, pal.accent_fg),
|
|
||||||
StrokeKind::Outside,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn icon_rect(center: egui::Pos2, size: f32) -> Rect {
|
|
||||||
Rect::from_center_size(center, Vec2::splat(size))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Знак приложения: квадрат с замком.
|
|
||||||
pub fn logo(ui: &mut Ui, size: f32) {
|
|
||||||
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
|
|
||||||
let p = ui.painter();
|
|
||||||
p.rect_filled(rect, CornerRadius::same((size * 0.16) as u8), ACCENT);
|
|
||||||
icons::paint(p, icon_rect(rect.center(), size * 0.58), Icon::Lock, Color32::WHITE);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn initial(title: &str) -> String {
|
|
||||||
title.chars().find(|c| c.is_alphanumeric()).map(|c| c.to_uppercase().collect()).unwrap_or_else(|| "·".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Нейтральная монограмма записи.
|
|
||||||
pub fn avatar(ui: &mut Ui, pal: &Pal, title: &str, size: f32) {
|
|
||||||
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
|
|
||||||
paint_avatar(ui, pal, rect.center(), size, title);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint_avatar(ui: &Ui, pal: &Pal, center: egui::Pos2, size: f32, title: &str) {
|
|
||||||
let p = ui.painter();
|
|
||||||
let rect = icon_rect(center, size);
|
|
||||||
p.rect(rect, CornerRadius::same(R_CTRL), pal.field, Stroke::new(1.0, pal.border), StrokeKind::Inside);
|
|
||||||
p.text(center, Align2::CENTER_CENTER, initial(title), FontId::new(size * 0.42, bold()), pal.weak);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate(s: &str, max: usize) -> String {
|
|
||||||
if s.chars().count() <= max { s.to_owned() } else { s.chars().take(max - 1).chain(['…']).collect() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Строка в списке записей.
|
|
||||||
pub fn entry_row(ui: &mut Ui, pal: &Pal, title: &str, subtitle: &str, favorite: bool, selected: bool) -> Response {
|
|
||||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 50.0), Sense::click());
|
|
||||||
if ui.is_rect_visible(rect) {
|
|
||||||
let inner = rect.shrink2(vec2(8.0, 1.0));
|
|
||||||
let bg = if selected {
|
|
||||||
pal.accent_soft
|
|
||||||
} else if resp.hovered() {
|
|
||||||
pal.hover
|
|
||||||
} else {
|
|
||||||
Color32::TRANSPARENT
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(inner, CornerRadius::same(R_CTRL), bg);
|
|
||||||
focus_ring(ui, inner, &resp, pal);
|
|
||||||
let seed = if title.is_empty() { "?" } else { title };
|
|
||||||
paint_avatar(ui, pal, pos2(inner.left() + 22.0, inner.center().y), 30.0, seed);
|
|
||||||
|
|
||||||
let right = inner.right() - if favorite { 28.0 } else { 10.0 };
|
|
||||||
let text_rect = Rect::from_min_max(pos2(inner.left() + 46.0, inner.top()), pos2(right, inner.bottom()));
|
|
||||||
let chars = ((text_rect.width() / 7.4) as usize).max(4);
|
|
||||||
let painter = ui.painter().with_clip_rect(text_rect);
|
|
||||||
let x = text_rect.left();
|
|
||||||
let y = inner.center().y;
|
|
||||||
let name = if title.is_empty() { "Без названия" } else { title };
|
|
||||||
painter.text(pos2(x, y - 8.0), Align2::LEFT_CENTER, truncate(name, chars), FontId::new(14.0, bold()), pal.text);
|
|
||||||
painter.text(
|
|
||||||
pos2(x, y + 9.0),
|
|
||||||
Align2::LEFT_CENTER,
|
|
||||||
truncate(subtitle, chars + 4),
|
|
||||||
FontId::proportional(12.5),
|
|
||||||
pal.weak,
|
|
||||||
);
|
|
||||||
if favorite {
|
|
||||||
icons::paint(ui.painter(), icon_rect(pos2(inner.right() - 16.0, y), 12.0), Icon::StarFilled, pal.warn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Пункт бокового меню.
|
|
||||||
pub fn nav_item(ui: &mut Ui, pal: &Pal, icon: Icon, label: &str, count: usize, selected: bool) -> Response {
|
|
||||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
|
||||||
if ui.is_rect_visible(rect) {
|
|
||||||
let bg = if selected {
|
|
||||||
pal.accent_soft
|
|
||||||
} else if resp.hovered() {
|
|
||||||
pal.hover
|
|
||||||
} else {
|
|
||||||
Color32::TRANSPARENT
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(rect, CornerRadius::same(R_CTRL), bg);
|
|
||||||
focus_ring(ui, rect, &resp, pal);
|
|
||||||
let y = rect.center().y;
|
|
||||||
let icon_color = if selected { pal.accent_fg } else { pal.weak };
|
|
||||||
icons::paint(ui.painter(), icon_rect(pos2(rect.left() + 18.0, y), 15.0), icon, icon_color);
|
|
||||||
let clip = Rect::from_min_max(rect.min, pos2(rect.right() - 32.0, rect.bottom()));
|
|
||||||
let font = if selected { FontId::new(14.0, bold()) } else { FontId::proportional(14.0) };
|
|
||||||
ui.painter().with_clip_rect(clip).text(pos2(rect.left() + 36.0, y), Align2::LEFT_CENTER, label, font, pal.text);
|
|
||||||
ui.painter().text(
|
|
||||||
pos2(rect.right() - 10.0, y),
|
|
||||||
Align2::RIGHT_CENTER,
|
|
||||||
count.to_string(),
|
|
||||||
FontId::proportional(12.0),
|
|
||||||
pal.weak,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn section_label(ui: &mut Ui, pal: &Pal, text: &str) {
|
|
||||||
ui.label(RichText::new(text).size(11.0).color(pal.weak));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn field_label(ui: &mut Ui, pal: &Pal, text: &str) {
|
|
||||||
ui.add_space(2.0);
|
|
||||||
ui.label(RichText::new(text).size(12.0).color(pal.weak));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
|
||||||
pub enum Tone {
|
|
||||||
Primary,
|
|
||||||
Secondary,
|
|
||||||
Danger,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Кнопка: необязательная иконка + текст. Высота 34, ширина не меньше `min_width`.
|
|
||||||
pub fn button(ui: &mut Ui, pal: &Pal, icon: Option<Icon>, text: &str, tone: Tone, min_width: f32) -> Response {
|
|
||||||
let solid = tone != Tone::Secondary;
|
|
||||||
let font = if solid { FontId::new(14.0, bold()) } else { FontId::new(14.0, FontFamily::Proportional) };
|
|
||||||
let fg = if solid { Color32::WHITE } else { pal.text };
|
|
||||||
let galley = ui.painter().layout_no_wrap(text.to_owned(), font, fg);
|
|
||||||
let icon_w = match (icon, text.is_empty()) {
|
|
||||||
(None, _) => 0.0,
|
|
||||||
(Some(_), true) => 16.0,
|
|
||||||
(Some(_), false) => 24.0,
|
|
||||||
};
|
|
||||||
let content_w = icon_w + galley.size().x;
|
|
||||||
let width = (content_w + 28.0).max(min_width);
|
|
||||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 34.0), Sense::click());
|
|
||||||
if ui.is_rect_visible(rect) {
|
|
||||||
let hot = resp.hovered() || resp.has_focus();
|
|
||||||
let enabled = ui.is_enabled();
|
|
||||||
let (fill, stroke) = match tone {
|
|
||||||
Tone::Primary => (if hot { pal.accent.gamma_multiply(1.12) } else { pal.accent }, Stroke::NONE),
|
|
||||||
Tone::Danger => (if hot { pal.danger.gamma_multiply(1.1) } else { pal.danger }, Stroke::NONE),
|
|
||||||
Tone::Secondary => (
|
|
||||||
if hot { pal.hover } else { Color32::TRANSPARENT },
|
|
||||||
Stroke::new(1.0, if hot { pal.weak } else { pal.control }),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.45) };
|
|
||||||
ui.painter().rect(rect, CornerRadius::same(R_CTRL), dim(fill), stroke, StrokeKind::Inside);
|
|
||||||
focus_ring(ui, rect, &resp, pal);
|
|
||||||
let x0 = rect.center().x - content_w / 2.0;
|
|
||||||
if let Some(i) = icon {
|
|
||||||
icons::paint(ui.painter(), icon_rect(pos2(x0 + 8.0, rect.center().y), 15.0), i, dim(fg));
|
|
||||||
}
|
|
||||||
let pos = pos2(x0 + icon_w, rect.center().y - galley.size().y / 2.0);
|
|
||||||
ui.painter().galley(pos, galley, dim(fg));
|
|
||||||
}
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn primary(ui: &mut Ui, pal: &Pal, text: &str, width: f32) -> Response {
|
|
||||||
button(ui, pal, None, text, Tone::Primary, width)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn secondary(ui: &mut Ui, pal: &Pal, text: &str, width: f32) -> Response {
|
|
||||||
button(ui, pal, None, text, Tone::Secondary, width)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn danger(ui: &mut Ui, pal: &Pal, text: &str) -> Response {
|
|
||||||
button(ui, pal, None, text, Tone::Danger, 110.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Квадратная кнопка-иконка без рамки. `hot` — цвет иконки при наведении.
|
|
||||||
pub fn icon_btn_tone(ui: &mut Ui, pal: &Pal, icon: Icon, tip: &str, on: bool, hot: Color32) -> Response {
|
|
||||||
let (rect, resp) = ui.allocate_exact_size(vec2(30.0, 30.0), Sense::click());
|
|
||||||
if ui.is_rect_visible(rect) {
|
|
||||||
let hovered = resp.hovered();
|
|
||||||
let bg = if on {
|
|
||||||
pal.accent_soft
|
|
||||||
} else if hovered {
|
|
||||||
pal.hover
|
|
||||||
} else {
|
|
||||||
Color32::TRANSPARENT
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(rect, CornerRadius::same(R_CTRL), bg);
|
|
||||||
focus_ring(ui, rect, &resp, pal);
|
|
||||||
let color = if on {
|
|
||||||
pal.accent_fg
|
|
||||||
} else if hovered {
|
|
||||||
hot
|
|
||||||
} else {
|
|
||||||
pal.weak
|
|
||||||
};
|
|
||||||
icons::paint(ui.painter(), icon_rect(rect.center(), 16.0), icon, color);
|
|
||||||
}
|
|
||||||
resp.on_hover_text(tip)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn icon_btn(ui: &mut Ui, pal: &Pal, icon: Icon, tip: &str) -> Response {
|
|
||||||
icon_btn_tone(ui, pal, icon, tip, false, pal.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn toggle_btn(ui: &mut Ui, pal: &Pal, icon: Icon, on: bool, tip: &str) -> Response {
|
|
||||||
icon_btn_tone(ui, pal, icon, tip, on, pal.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn text_field(ui: &mut Ui, value: &mut String, hint: &str) -> Response {
|
|
||||||
ui.add(TextEdit::singleline(value).hint_text(hint).desired_width(f32::INFINITY).margin(Margin::symmetric(10, 7)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Поле поиска с нарисованной лупой.
|
|
||||||
pub fn search_field(ui: &mut Ui, pal: &Pal, value: &mut String, hint: &str, width: f32) -> Response {
|
|
||||||
let r = ui.add(TextEdit::singleline(value).hint_text(hint).desired_width(width).margin(Margin {
|
|
||||||
left: 32,
|
|
||||||
right: 10,
|
|
||||||
top: 7,
|
|
||||||
bottom: 7,
|
|
||||||
}));
|
|
||||||
let center = pos2(r.rect.left() + 17.0, r.rect.center().y);
|
|
||||||
icons::paint(ui.painter(), icon_rect(center, 14.0), Icon::Search, pal.weak);
|
|
||||||
r
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Поле пароля с кнопкой показать/скрыть. Дополнительные кнопки — через `extra`.
|
|
||||||
pub fn password_field(
|
|
||||||
ui: &mut Ui,
|
|
||||||
pal: &Pal,
|
|
||||||
value: &mut String,
|
|
||||||
show: &mut bool,
|
|
||||||
hint: &str,
|
|
||||||
extra: impl FnOnce(&mut Ui),
|
|
||||||
) -> Response {
|
|
||||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 34.0), Layout::right_to_left(Align::Center), |ui| {
|
|
||||||
extra(ui);
|
|
||||||
let icon = if *show { Icon::EyeOff } else { Icon::Eye };
|
|
||||||
if icon_btn(ui, pal, icon, if *show { "Скрыть" } else { "Показать" }).clicked() {
|
|
||||||
*show = !*show;
|
|
||||||
}
|
|
||||||
let edit = TextEdit::singleline(value)
|
|
||||||
.password(!*show)
|
|
||||||
.hint_text(hint)
|
|
||||||
.desired_width(f32::INFINITY)
|
|
||||||
.margin(Margin::symmetric(10, 7));
|
|
||||||
let edit = if *show { edit.font(egui::TextStyle::Monospace) } else { edit };
|
|
||||||
ui.add(edit)
|
|
||||||
})
|
|
||||||
.inner
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn strength_bar(ui: &mut Ui, pal: &Pal, pw: &str) {
|
|
||||||
let s = generator::strength(pw);
|
|
||||||
let colors = [pal.danger, pal.danger, pal.warn, pal.success, pal.success];
|
|
||||||
let color = colors[s.score as usize];
|
|
||||||
let filled = if pw.is_empty() { 0 } else { s.score as usize + 1 };
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.spacing_mut().item_spacing.x = 3.0;
|
|
||||||
for i in 0..5 {
|
|
||||||
let (rect, _) = ui.allocate_exact_size(vec2(30.0, 3.0), Sense::hover());
|
|
||||||
ui.painter().rect_filled(rect, CornerRadius::same(1), if i < filled { color } else { pal.border });
|
|
||||||
}
|
|
||||||
ui.add_space(8.0);
|
|
||||||
if !pw.is_empty() {
|
|
||||||
ui.label(RichText::new(format!("{} · ~{:.0} бит", s.label(), s.bits)).size(12.0).color(pal.weak));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Маленькая цветная точка-индикатор.
|
|
||||||
pub fn dot(ui: &mut Ui, color: Color32) {
|
|
||||||
let (rect, _) = ui.allocate_exact_size(vec2(8.0, 14.0), Sense::hover());
|
|
||||||
ui.painter().circle_filled(rect.center(), 3.0, color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Иконка + текст с переносом (предупреждения, подсказки).
|
|
||||||
pub fn note(ui: &mut Ui, icon: Icon, text: &str, color: Color32) {
|
|
||||||
ui.horizontal_top(|ui| {
|
|
||||||
let (rect, _) = ui.allocate_exact_size(vec2(16.0, 17.0), Sense::hover());
|
|
||||||
icons::paint(ui.painter(), icon_rect(rect.center(), 14.0), icon, color);
|
|
||||||
ui.add(egui::Label::new(RichText::new(text).size(12.5).color(color)).wrap());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_ms(ms: i64) -> String {
|
|
||||||
chrono::DateTime::from_timestamp_millis(ms)
|
|
||||||
.map(|d| d.with_timezone(&chrono::Local).format("%d.%m.%Y %H:%M").to_string())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn display_url(url: &str) -> &str {
|
|
||||||
let u = url.trim();
|
|
||||||
let u = u.strip_prefix("https://").or_else(|| u.strip_prefix("http://")).unwrap_or(u);
|
|
||||||
u.strip_suffix('/').unwrap_or(u)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn full_url(url: &str) -> String {
|
|
||||||
let u = url.trim();
|
|
||||||
if u.contains("://") { u.to_owned() } else { format!("https://{u}") }
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
//! Заметки проекта не должны разрастаться: каждый файл в `notes/` объявляет
|
|
||||||
//! лимит строк в первой строке (`<!-- limit: N -->`), тест следит за ним.
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn notes_fit_their_line_limits() {
|
|
||||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("notes");
|
|
||||||
let mut problems = Vec::new();
|
|
||||||
let mut checked = 0;
|
|
||||||
|
|
||||||
for entry in fs::read_dir(&dir).expect("папка notes/") {
|
|
||||||
let path = entry.unwrap().path();
|
|
||||||
if path.extension().is_none_or(|e| e != "md") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
|
||||||
let text = fs::read_to_string(&path).unwrap();
|
|
||||||
let first = text.lines().next().unwrap_or_default();
|
|
||||||
let limit = first
|
|
||||||
.trim()
|
|
||||||
.strip_prefix("<!-- limit:")
|
|
||||||
.and_then(|s| s.strip_suffix("-->"))
|
|
||||||
.and_then(|s| s.trim().parse::<usize>().ok());
|
|
||||||
let Some(limit) = limit else {
|
|
||||||
problems.push(format!("{name}: нет заголовка `<!-- limit: N -->`"));
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let lines = text.lines().count();
|
|
||||||
if lines > limit {
|
|
||||||
problems.push(format!(
|
|
||||||
"{name}: {lines} строк при лимите {limit} — сократите (LOG.md: перенесите старое в notes/archive/)"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
checked += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(checked > 0, "в notes/ нет ни одного .md");
|
|
||||||
assert!(problems.is_empty(), "\n{}", problems.join("\n"));
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user