diff --git a/.gitattributes b/.gitattributes index cf34f33..df6e38b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -# cmd.exe is sensitive to line endings — a run.bat checked out with LF can +# cmd.exe is sensitive to line endings — a build.bat checked out with LF can # misparse labels and multi-line constructs — while sh requires LF regardless # of the platform it is checked out on. *.bat text eol=crlf diff --git a/Cargo.lock b/Cargo.lock index 7dc3755..8007f42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,6 +131,19 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.12", + "password-hash 0.5.0", + "zeroize", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -374,6 +387,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -559,13 +581,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.99" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c51067fd44124faa7f870b4b1c969379ad32b2ba805aa959430ceaa384f695" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ + "find-msvc-tools", "jobserver", "libc", - "once_cell", + "shlex", ] [[package]] @@ -724,7 +747,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -839,6 +862,28 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "openssl", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.3" @@ -1273,6 +1318,12 @@ dependencies = [ "libredox", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.9" @@ -1295,6 +1346,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1302,7 +1368,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1316,6 +1382,12 @@ dependencies = [ "syn 2.0.66", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1577,22 +1649,22 @@ dependencies = [ "num-traits", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", ] [[package]] @@ -1603,11 +1675,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.9.1" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.16.1", ] [[package]] @@ -1993,6 +2065,20 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "openssl", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "khronos_api" version = "3.1.0" @@ -2025,6 +2111,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -2055,11 +2151,12 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", + "openssl-sys", "pkg-config", "vcpkg", ] @@ -2752,6 +2849,54 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "orbclient" version = "0.3.55" @@ -2821,6 +2966,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -2835,7 +2991,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest", "hmac", - "password-hash", + "password-hash 0.4.2", "sha2", ] @@ -3081,9 +3237,11 @@ dependencies = [ [[package]] name = "quicksearch-core" -version = "0.1.0" +version = "0.8.0" dependencies = [ + "argon2", "ctrlc", + "getrandom 0.2.15", "globset", "infer", "kamadak-exif", @@ -3100,22 +3258,26 @@ dependencies = [ "toml", "walkdir", "windows-sys 0.52.0", + "zeroize", "zip", "zstd 0.13.3", ] [[package]] name = "quicksearch-gui" -version = "0.1.0" +version = "0.8.0" dependencies = [ "chrono", "eframe", "egui", "egui_extras", + "keyring", "open", "quicksearch-core", "rfd", + "rpassword", "windows-sys 0.59.0", + "zeroize", ] [[package]] @@ -3332,10 +3494,41 @@ dependencies = [ ] [[package]] -name = "rusqlite" -version = "0.31.0" +name = "rpassword" +version = "7.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.19", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ "bitflags 2.13.1", "fallible-iterator", @@ -3343,6 +3536,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -3497,6 +3691,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -3630,6 +3830,18 @@ dependencies = [ "serde", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -4556,6 +4768,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.4", +] + [[package]] name = "windows-sys" version = "0.61.0" @@ -4604,13 +4825,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -4629,6 +4867,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4647,6 +4891,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4665,12 +4915,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4689,6 +4951,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4707,6 +4975,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4725,6 +4999,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4743,6 +5023,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winit" version = "0.30.13" @@ -5039,6 +5325,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "zerovec" version = "0.10.2" diff --git a/Cargo.toml b/Cargo.toml index c69b97f..c967d1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ ] [workspace.package] -version = "0.8.0" +version = "0.8.5" edition = "2021" license = "GPL-3.0-or-later" authors = ["Jeremy "] diff --git a/README.md b/README.md index 40ddd2f..4af1a58 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,40 @@ compact egui desktop app, or straight to your terminal. ## Build & run -Requirements: a Rust toolchain (edition 2021). The old WebKit/WebView -dependencies (`setup.sh`) are gone; the GUI renders with OpenGL via egui. +```sh +./build.sh # Linux +build.bat # Windows +``` -SQLite and zstd are compiled from bundled C sources, so a C toolchain is -required on every platform: +These take a fresh machine all the way to a running app: install whatever +build dependencies are missing, build release, then launch the GUI. Each +setup stage is skipped when what it provides is already there, so a normal +run costs one `cargo build`. `build.sh` installs Linux system packages with +the distribution's package manager via `sudo` (apt/dnf/pacman/zypper) and the +Rust toolchain with rustup; `build.bat` uses winget and rustup. Both take +`--check` to report dependency status without installing or building, +`--no-run` to stop after the build, and `--` to pass the rest to the binary. + +Building by hand needs a Rust toolchain (edition 2021) plus, on every +platform, a C toolchain and Perl: SQLCipher, zstd and OpenSSL are compiled +from bundled C sources, and OpenSSL's `Configure` is a Perl script. The old +WebKit/WebView dependencies (`setup.sh`) are gone; the GUI renders with +OpenGL via egui. - Linux: working OpenGL 3.3 drivers; `xdg-desktop-portal` (present on all mainstream desktops) provides the native folder picker. On minimal - images you may need `build-essential pkg-config libxkbcommon-dev`. + images you may need `build-essential perl pkg-config`. No X11, Wayland or + xkbcommon `-dev` packages are needed: winit dlopens the display stack at + run time, so only the runtime libraries matter. - Windows: Visual Studio 2022 Build Tools with the "Desktop development - with C++" workload (MSVC v143 plus a Windows SDK). For the GNU target - instead, `rustup target add x86_64-pc-windows-gnu` and a mingw-w64 - toolchain. Note that Windows ships only a software OpenGL 1.1 driver, so - a bare VM or an RDP session without a vendor GPU driver cannot create a - context and the window will fail to open. -- macOS: Xcode command line tools. + with C++" workload (MSVC v143 plus a Windows SDK), and Perl (Strawberry + Perl); NASM is optional and only enables OpenSSL's assembly paths. For the + GNU target instead, `rustup target add x86_64-pc-windows-gnu` and a + mingw-w64 toolchain. Note that Windows ships only a software OpenGL 1.1 + driver, so a bare VM or an RDP session without a vendor GPU driver cannot + create a context and the window will fail to open. +- macOS: Xcode command line tools (`build.sh` does not auto-install these — + only Linux package managers are handled). ```sh cargo build --release -p quicksearch-gui # binaries: target/release/quicksearch{,-cli} @@ -31,8 +49,6 @@ cargo run -p quicksearch-gui # or just run it cargo test -p quicksearch-core # backend test suite ``` -`run.sh` / `run.bat` wrap the same commands. - Two binaries are produced. `quicksearch` is the desktop app; on Windows it is built as a window-subsystem app so no console appears behind it. `quicksearch-cli` is terminal search — a console app, so pipes, redirection @@ -95,10 +111,11 @@ the package is installed. - **Search**: results appear as you type; every keystroke cancels the previous search. One checkbox enables the two fuzzy passes. Sort by - rank, name, path, size, or modified; right-click a result to open it, - reveal it in the file manager, or build an ignore filter from it - (session-only by default, optionally persisted to the config). Matches - in file contents show highlighted snippets. + rank, name, path, size, or modified. Double-click a result to open it; + right-click it to reveal it in the file manager, open it, copy its + path, or build an ignore filter from it (session-only by default, + optionally persisted to the config). Result text can be selected and + copied in place. Matches in file contents show highlighted snippets. - **Manage Index**: full indexing status, Start/Stop/Automatic controls, indexed folder list, full-text extension filters, ignore patterns, and the indexing options. @@ -108,6 +125,8 @@ the package is installed. filter box and Copy button. Launched from a desktop launcher (or on Windows, where the app has no console at all) this is the only place they are visible. +- **Help**: an in-app quickstart — first indexing run, example queries, + what each tab does — pointing here for everything technical. The bottom status bar always shows what the indexer is doing (phase, percent, files/sec) or the total indexed file count when idle. @@ -128,6 +147,37 @@ opens the app, and any query given to it seeds the search box instead of printing. Colour in `--long` output needs a console with virtual-terminal processing; Windows Terminal has it, and older consoles get plain text. +### Password protection + +The index contains the names and (by default) the full text of everything +it indexes — for most setups, your entire home directory. That is a lot of +concentrated risk in one file. **Options → Security → Enable password +protection** encrypts the index on disk with SQLCipher; from then on +QuickSearch asks for the password every time it starts, in the GUI (an +unlock screen before anything opens the index) and in the terminal (a +hidden prompt). Enabling, disabling, or changing the password deletes and +rebuilds the index — there is no in-place conversion. + +- The key is derived as `Argon2id(password, salt)`; the salt is written to + `config.toml` when the password is set (it is unique, not secret, and + required — keep it with the config if you copy a protected setup). +- **Remember on this device** stores the derived key (never the password) + in the OS keychain — Secret Service/KWallet on Linux, Credential Manager + on Windows — and skips the prompt. Without a keychain daemon the option + quietly falls back to prompting. +- Scripts can set `QUICKSEARCH_PASSWORD` for non-interactive terminal + search. Environment variables are readable by other processes of the + same user (`/proc//environ`) — prefer the keychain. +- Forgot the password? The unlock screen can delete the index and disable + protection; your files are untouched and re-indexing rebuilds it. + +What this protects: the index file at rest — disk theft, backups, other +accounts reading the file. What it does not protect: a compromised running +session (the derived key is in process memory while the app runs), and the +files themselves, which are exactly as readable as before. A wrong +password can never wipe the index; it is refused without touching the +file. + ### Query syntax This section is the complete reference (the in-app "?" popup shows a @@ -210,7 +260,8 @@ crates/quicksearch-gui binary "quicksearch": egui app + terminal mode Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime. -- **Storage** (`db/`): SQLite via rusqlite (bundled), WAL mode so the +- **Storage** (`db/`): SQLite via rusqlite (bundled SQLCipher build — + identical to stock SQLite until a key is applied), WAL mode so the single writer never blocks streaming read-only searches. `files` holds metadata (name, path, size, mtime, hash, MIME/type bitmask, per-row index state); `searchabletext` is a *contentless* FTS5 table (postings @@ -219,7 +270,11 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime. occurrence ranking, and fuzzy full-text search. Schema changes wipe and rebuild by policy; the indexer (`open_or_recreate`) is the only code allowed to do that; every consumer uses `open_existing`, which treats - drift as an error, never data loss. + drift as an error, never data loss. With password protection on, every + open applies the Argon2id-derived raw key (`security.rs`, process-global + in `db/key.rs`) before anything reads the file; a wrong key is a tagged + `KEY_MISMATCH` error, structurally distinct from the schema drift that + may wipe, so it can never destroy an intact index. - **Indexing** (`indexing.rs`, `file_handling.rs`): full runs walk each root (`filtered_walk` prunes hidden/ignored subtrees before descending), classify files by mtime into insert/update/skip, batch-write metadata, diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..e10a9be --- /dev/null +++ b/build.bat @@ -0,0 +1,214 @@ +@echo off +rem Set up the build environment, build QuickSearch, and launch the GUI. The +rem Windows counterpart of build.sh; run "build.bat --help" for usage. +rem +rem Terminal search is a separate binary on Windows, because the GUI is built +rem as a window-subsystem app and cannot write to the calling shell: +rem target\release\quicksearch-cli.exe --help +rem +rem A fresh machine needs three things, all installed here when missing and +rem skipped when already present: +rem * the MSVC C++ build tools - SQLCipher, zstd and OpenSSL are compiled +rem from bundled C sources, so a C toolchain is required; +rem * Perl - OpenSSL's Configure is a Perl script, and rusqlite's +rem bundled-sqlcipher-vendored-openssl feature builds OpenSSL; +rem * the Rust toolchain, via rustup. +rem NASM is optional: openssl-src uses it for assembly optimisations when it is +rem on PATH and builds without it otherwise. +setlocal EnableExtensions + +set "ROOT=%~dp0" +set "DO_RUN=1" +set "MODE=run" +set "ARGS=" +set "MISSING=" + +:parse +if "%~1"=="" goto parsed +if /i "%~1"=="--no-run" ( + set "DO_RUN=0" + shift + goto parse +) +if /i "%~1"=="--check" ( + set "MODE=check" + shift + goto parse +) +if /i "%~1"=="-h" goto help +if /i "%~1"=="--help" goto help +if "%~1"=="--" ( + shift + goto collect +) +rem Not ours: this argument and everything after it belongs to the binary. +goto collect + +rem shift does not rewrite %*, so the binary's arguments are re-accumulated by +rem hand. Each pass re-parses this line, so %ARGS% is always the current value. +:collect +if "%~1"=="" goto parsed +set "ARGS=%ARGS% "%~1"" +shift +goto collect + +:help +echo Set up the build environment, build QuickSearch, and launch the GUI. +echo. +echo build.bat install what is missing, build release, launch +echo build.bat --no-run stop after the build +echo build.bat --check report dependency status; install and build nothing +echo build.bat -- ^ pass everything after -- to the launched binary +echo. +echo Anything unrecognised ends option parsing and reaches the binary; use -- +echo for arguments that look like flags. +exit /b 0 + +:parsed +set "WINGET=" +where winget >nul 2>&1 && set "WINGET=1" + +rem ------------------------------------------------------------- MSVC ------ + +where cl >nul 2>&1 && goto msvc_ok +rem cl is only on PATH inside a developer prompt, so an ordinary shell asks +rem vswhere instead. cargo (through cc-rs) locates MSVC the same way, which is +rem why nothing has to be added to PATH after installing it. +set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +set "VCDIR=" +if not exist "%VSWHERE%" goto msvc_missing +for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2^>nul`) do set "VCDIR=%%i" +if defined VCDIR goto msvc_ok + +:msvc_missing +if "%MODE%"=="check" ( + echo MSVC C++ build tools: MISSING + set "MISSING=1" + goto msvc_done +) +if not defined WINGET goto msvc_manual +echo ==^> Installing Visual Studio 2022 Build Tools ^(a multi-gigabyte download^) +rem --includeRecommended is what pulls in the Windows SDK alongside VCTools. +winget install -e --id Microsoft.VisualStudio.2022.BuildTools --accept-source-agreements --accept-package-agreements --override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" +if errorlevel 1 goto msvc_manual +goto msvc_done + +:msvc_manual +echo build: the MSVC C++ toolchain is required. Install it with: >&2 +echo winget install -e --id Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" >&2 +echo or get the Build Tools from https://visualstudio.microsoft.com/downloads/ >&2 +echo and select the "Desktop development with C++" workload. >&2 +exit /b 1 + +:msvc_ok +if "%MODE%"=="check" echo MSVC C++ build tools: ok +:msvc_done + +rem ------------------------------------------------------------- Perl ------ + +where perl >nul 2>&1 && goto perl_ok +if exist "C:\Strawberry\perl\bin\perl.exe" goto perl_path +if "%MODE%"=="check" ( + echo Perl: MISSING + set "MISSING=1" + goto perl_done +) +if not defined WINGET goto perl_manual +echo ==^> Installing Strawberry Perl +winget install -e --id StrawberryPerl.StrawberryPerl --accept-source-agreements --accept-package-agreements +if errorlevel 1 goto perl_manual +if not exist "C:\Strawberry\perl\bin\perl.exe" goto perl_manual + +:perl_path +rem A just-installed package is not on the PATH of this already-running shell. +set "PATH=C:\Strawberry\perl\bin;C:\Strawberry\c\bin;%PATH%" +goto perl_done + +:perl_manual +echo build: Perl is required ^(OpenSSL's Configure is a Perl script^). Install it with: >&2 +echo winget install -e --id StrawberryPerl.StrawberryPerl >&2 +echo or get it from https://strawberryperl.com/ >&2 +exit /b 1 + +:perl_ok +if "%MODE%"=="check" echo Perl: ok +:perl_done + +rem ------------------------------------------------------------- NASM ------ + +where nasm >nul 2>&1 && goto nasm_ok +if exist "%ProgramFiles%\NASM\nasm.exe" goto nasm_path +if "%MODE%"=="check" ( + echo NASM: missing ^(optional^) + goto nasm_done +) +if not defined WINGET goto nasm_skip +echo ==^> Installing NASM ^(optional: OpenSSL assembly optimisations^) +winget install -e --id NASM.NASM --accept-source-agreements --accept-package-agreements +if errorlevel 1 goto nasm_skip +if not exist "%ProgramFiles%\NASM\nasm.exe" goto nasm_skip + +:nasm_path +set "PATH=%ProgramFiles%\NASM;%PATH%" +goto nasm_done + +rem Never fatal: OpenSSL falls back to a build without assembly optimisations. +:nasm_skip +echo build: NASM unavailable; OpenSSL will build without assembly optimisations. +goto nasm_done + +:nasm_ok +if "%MODE%"=="check" echo NASM: ok +:nasm_done + +rem ------------------------------------------------------------- Rust ------ + +where cargo >nul 2>&1 && goto cargo_ok +rem rustup edits the user's PATH, which an already-running shell never sees. +if exist "%USERPROFILE%\.cargo\bin\cargo.exe" goto cargo_path +if "%MODE%"=="check" ( + echo Rust: MISSING ^(rustup would install it^) + set "MISSING=1" + goto cargo_done +) +where curl >nul 2>&1 || goto rust_manual +echo ==^> Installing the Rust toolchain ^(rustup^) +curl -sSfLo "%TEMP%\rustup-init.exe" https://win.rustup.rs/x86_64 +if errorlevel 1 goto rust_manual +"%TEMP%\rustup-init.exe" -y --profile minimal --default-toolchain stable-x86_64-pc-windows-msvc +if errorlevel 1 goto rust_manual +del "%TEMP%\rustup-init.exe" >nul 2>&1 +if not exist "%USERPROFILE%\.cargo\bin\cargo.exe" goto rust_manual + +:cargo_path +set "PATH=%USERPROFILE%\.cargo\bin;%PATH%" +goto cargo_done + +:rust_manual +echo build: could not install Rust automatically; get it from https://rustup.rs/ >&2 +exit /b 1 + +:cargo_ok +if "%MODE%"=="check" for /f "tokens=*" %%v in ('cargo --version') do echo Rust: %%v +:cargo_done + +rem ------------------------------------------------------------ build ------ + +if "%MODE%"=="check" ( + if defined MISSING exit /b 1 + echo ==^> Ready to build + exit /b 0 +) + +rem %~dp0 is this script's own directory (with a trailing backslash), so neither +rem the build nor the launch depends on the current working directory. +cd /d "%ROOT%" +cargo build --release -p quicksearch-gui +if errorlevel 1 exit /b 1 + +if "%DO_RUN%"=="0" ( + echo ==^> Built target\release\quicksearch.exe and target\release\quicksearch-cli.exe + exit /b 0 +) + +"%ROOT%target\release\quicksearch.exe"%ARGS% diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..1036c2b --- /dev/null +++ b/build.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env sh +# +# Set up the build environment, build QuickSearch, and launch the GUI. On Unix +# the same binary doubles as the terminal search tool: +# ./target/release/quicksearch --help +# (On Windows that role belongs to quicksearch-cli.exe — see build.bat.) +# +# ./build.sh install what is missing, build release, launch +# ./build.sh --no-run stop after the build +# ./build.sh --check report dependency status; install and build nothing +# ./build.sh -- pass everything after -- to the launched binary +# +# Anything the script does not recognise ends its own option parsing, so a bare +# search term reaches the binary; use -- for arguments that look like flags. +# +# On a fresh Linux machine this installs the C toolchain, Perl and pkg-config +# with the distribution's package manager (via sudo), then the Rust toolchain +# with rustup. Every stage is skipped when what it provides is already present, +# so the everyday run costs one `cargo build`. +# Other Unixes only get the Rust stage — see the README for their toolchains. +set -e + +# Not `dirname`: a script whose job is to install missing tools should lean on +# as few of them as possible. $0 has no slash when the script is found on PATH. +case "$0" in + */*) script_dir="${0%/*}" ;; + *) script_dir=. ;; +esac +REPO_ROOT="$(cd -- "$script_dir" && pwd)" + +die() { printf 'build: %s\n' "$*" >&2; exit 1; } +say() { printf '\033[1m==>\033[0m %s\n' "$*"; } +need_cmd() { command -v "$1" >/dev/null 2>&1; } + +do_run=1 +mode=run + +while [ $# -gt 0 ]; do + case "$1" in + --no-run) do_run=0 ;; + --check) mode=check ;; + # Print the header comment block, however long it grows. + -h|--help) awk 'NR > 1 { if ($0 !~ /^#/) exit; sub(/^# ?/, ""); print }' "$0"; exit 0 ;; + --) shift; break ;; + # Not ours: it and everything after it belongs to the binary. + *) break ;; + esac + shift +done + +# ------------------------------------------------------------ toolchain ---- + +# cargo may be installed but absent from this shell's PATH, which is the normal +# state right after rustup runs: it appends to the shell profile, and +# ~/.cargo/env is what it writes for processes that cannot wait for a new login. +have_cargo() { + if need_cmd cargo; then return 0; fi + if [ -r "$HOME/.cargo/env" ]; then + . "$HOME/.cargo/env" + if need_cmd cargo; then return 0; fi + fi + return 1 +} + +ensure_rust() { + if have_cargo; then return 0; fi + need_cmd curl || die "curl is needed to fetch rustup" + say "Installing the Rust toolchain (rustup, into ~/.rustup and ~/.cargo)" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable + if [ -r "$HOME/.cargo/env" ]; then . "$HOME/.cargo/env"; fi + need_cmd cargo || die "rustup finished but cargo is still not on PATH" +} + +# -------------------------------------------------------- system packages --- + +# Sets $missing to the space-separated tools that are absent. Probing for the +# command rather than for a package keeps this identical across distributions +# and honours toolchains installed outside the package manager. +# +# Perl is not optional despite never being named in the source: rusqlite's +# bundled-sqlcipher-vendored-openssl feature builds OpenSSL, whose Configure is +# a Perl script. Without it the failure comes hundreds of crates into the build. +check_deps() { + missing='' + if ! need_cmd cc && ! need_cmd gcc && ! need_cmd clang; then missing="$missing cc"; fi + need_cmd make || missing="$missing make" + need_cmd perl || missing="$missing perl" + # pkg-config is how x11-dl and wayland-sys locate the display libraries. + # No -dev packages for those libraries are needed: winit dlopens the whole + # display stack at run time (xkbcommon-dl has no build script at all, and + # x11-dl's records a libdir of None when a .pc file is absent), so headers + # never enter the build. + need_cmd pkg-config || missing="$missing pkg-config" + # curl is a build dependency only while rustup still has to be downloaded. + if ! have_cargo && ! need_cmd curl; then missing="$missing curl"; fi +} + +# The tool -> package mapping, per package manager. Packages named twice (a +# single build-essential covers both cc and make) are deduplicated by the caller. +packages_for() { + case "$1:$2" in + apt-get:cc|apt-get:make) echo build-essential ;; + apt-get:*) echo "$2" ;; + + dnf:cc) echo gcc gcc-c++ ;; + dnf:pkg-config) echo pkgconf-pkg-config ;; + dnf:*) echo "$2" ;; + + pacman:cc|pacman:make) echo base-devel ;; + pacman:pkg-config) echo pkgconf ;; + pacman:*) echo "$2" ;; + + zypper:cc) echo gcc gcc-c++ ;; + zypper:*) echo "$2" ;; + esac +} + +# Sets $sudo_cmd, and returns non-zero when the packages cannot be installed +# from here. $sudo_cmd is set either way, so the commands can still be printed +# for the user to run as root. +resolve_sudo() { + if [ "$(id -u)" = 0 ]; then sudo_cmd=''; return 0; fi + sudo_cmd=sudo + need_cmd sudo +} + +# Run or print one package-manager command. Both paths go through here so what +# --check reports cannot drift from what the install stage actually does. +pm_exec() { + if [ "$mode" = run ] && [ "$can_install" = 1 ]; then + say "$*" + "$@" + else + printf ' %s\n' "$*" + fi +} + +# $sudo_cmd and $pkgs are deliberately unquoted: an empty sudo_cmd has to +# disappear rather than become an empty argument, and pkgs has to split. +pm_commands() { + case "$pm" in + apt-get) + pm_exec $sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get update + pm_exec $sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get install -y $pkgs + ;; + dnf) pm_exec $sudo_cmd dnf install -y $pkgs ;; + pacman) pm_exec $sudo_cmd pacman -S --needed --noconfirm $pkgs ;; + zypper) pm_exec $sudo_cmd zypper --non-interactive install $pkgs ;; + esac +} + +ensure_system_deps() { + check_deps + [ -n "$missing" ] || return 0 + + pm='' + for candidate in apt-get dnf pacman zypper; do + if need_cmd "$candidate"; then pm="$candidate"; break; fi + done + [ -n "$pm" ] || die "missing build dependencies:$missing (no apt-get, dnf, pacman or zypper here — install them with your distribution's tools)" + + pkgs='' + for tool in $missing; do + pkgs="$pkgs $(packages_for "$pm" "$tool")" + done + pkgs="$(printf '%s\n' $pkgs | sort -u | tr '\n' ' ')" + + can_install=1 + resolve_sudo || can_install=0 + + if [ "$mode" != run ]; then + printf 'Install with:\n' + pm_commands + return 0 + fi + if [ "$can_install" = 0 ]; then + printf 'build: missing build dependencies:%s\nRun these as root, then try again:\n' "$missing" >&2 + pm_commands >&2 + exit 1 + fi + + say "Installing build dependencies:$missing" + pm_commands + + check_deps + [ -z "$missing" ] || die "still missing after installing packages:$missing" +} + +# ------------------------------------------------------------- stages ------ + +case "$(uname -s)" in + Linux) + ensure_system_deps + ;; + *) + # Only Linux package managers are handled; elsewhere the C toolchain is + # a manual step (on macOS: xcode-select --install). See the README. + check_deps + [ -z "$missing" ] || say "not Linux — install these yourself, see the README:$missing" + ;; +esac + +if [ "$mode" = check ]; then + if have_cargo; then + printf ' %s\n' "cargo: $(cargo --version)" + else + printf ' %s\n' "cargo: MISSING (rustup would install it)" + fi + [ -z "$missing" ] || exit 1 + say "Ready to build" + exit 0 +fi + +ensure_rust + +cd "$REPO_ROOT" +cargo build --release -p quicksearch-gui + +if [ "$do_run" = 0 ]; then + say "Built target/release/quicksearch and target/release/quicksearch-cli" + exit 0 +fi + +exec "$REPO_ROOT/target/release/quicksearch" "$@" diff --git a/config_example.toml b/config_example.toml index 00514f4..8e247c7 100644 --- a/config_example.toml +++ b/config_example.toml @@ -89,6 +89,20 @@ tokenize = "trigram" # previews, occurrence ranking, case verification, and fuzzy full-text. store_text_for_snippets = true +[security] +# Encrypt the index with a password (SQLCipher). The password is asked +# for every time QuickSearch starts; turning this on or off deletes and +# rebuilds the index. Change it from the GUI (Options → Security), not by +# hand: enabling protection also generates the KDF salt below. +password_protected = false +# Store the derived key in the OS keychain (Secret Service / KWallet on +# Linux, Credential Manager on Windows) and skip the startup prompt. +use_keychain = false +# When a password is set, the app writes a `salt` value here (32 hex +# digits). It is not a secret, but it is unique to your index: do not +# create or edit it by hand, and keep it if you copy this file — the +# password only unlocks the index together with its salt. + [ui] # Zoom factor for the whole GUI: fonts, spacing, and widgets scale # together (0.5 – 2.5). Ctrl +/- and Ctrl 0 adjust it temporarily at diff --git a/crates/quicksearch-core/Cargo.toml b/crates/quicksearch-core/Cargo.toml index 3d93333..69670e0 100644 --- a/crates/quicksearch-core/Cargo.toml +++ b/crates/quicksearch-core/Cargo.toml @@ -12,7 +12,15 @@ name = "quicksearch_core" path = "src/lib.rs" [dependencies] -rusqlite = { version = "0.31.0", features = ["bundled"] } +# `bundled-sqlcipher-vendored-openssl` compiles the SQLCipher amalgamation +# (a superset of the stock SQLite `bundled` build — FTS5 etc. included) and +# statically links a vendored OpenSSL libcrypto, so encryption support adds +# no runtime library dependencies. With no `PRAGMA key` applied, SQLCipher +# behaves identically to stock SQLite, so unencrypted indexes are unaffected. +rusqlite = { version = "0.39", features = ["bundled-sqlcipher-vendored-openssl"] } +argon2 = { version = "0.5", features = ["zeroize"] } +zeroize = { version = "1", features = ["derive"] } +getrandom = "0.2" sha2 = "0.10.8" walkdir = "2.5.0" zip = "0.6" diff --git a/crates/quicksearch-core/examples/build_protected_index.rs b/crates/quicksearch-core/examples/build_protected_index.rs new file mode 100644 index 0000000..ea56fc9 --- /dev/null +++ b/crates/quicksearch-core/examples/build_protected_index.rs @@ -0,0 +1,45 @@ +//! Build a password-protected index over a directory, for exercising the +//! CLI binaries against a real encrypted index without the GUI. +//! +//! cargo run -p quicksearch-core --example build_protected_index -- \ +//! + +use std::time::{Duration, Instant}; + +use quicksearch_core::config::Config; +use quicksearch_core::db; +use quicksearch_core::indexing::{IndexingService, IndexingStatus}; +use quicksearch_core::security::{derive_key, salt_from_hex}; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let [root, db_path, salt_hex, password] = args.as_slice() else { + eprintln!("usage: build_protected_index "); + std::process::exit(2); + }; + let salt = salt_from_hex(salt_hex).expect("valid salt hex"); + db::set_process_key(Some(derive_key(password, &salt))); + + let mut config = Config::default(); + config.indexing.auto_index = false; + let service = IndexingService::new(); + service + .start_indexing(vec![root.clone()], db_path.clone(), config) + .expect("indexing starts"); + + let deadline = Instant::now() + Duration::from_secs(120); + loop { + assert!(Instant::now() < deadline, "indexing timed out"); + if let IndexingStatus::Error(e) = service.get_status() { + panic!("indexing failed: {}", e); + } + if let Ok(conn) = db::open_existing(db_path, false) { + if db::repo::get_last_full_index(&conn).is_some() { + break; + } + } + std::thread::sleep(Duration::from_millis(20)); + } + service.stop_indexing().expect("clean stop"); + println!("protected index built at {}", db_path); +} diff --git a/crates/quicksearch-core/examples/memprobe.rs b/crates/quicksearch-core/examples/memprobe.rs new file mode 100644 index 0000000..3059a3f --- /dev/null +++ b/crates/quicksearch-core/examples/memprobe.rs @@ -0,0 +1,415 @@ +//! Peak-memory accounting for a full indexing run. +//! +//! [`indexprobe`](indexprobe.rs) answers "how fast"; this answers "how much +//! RAM", which is the number that decides whether indexing a large root is +//! usable on a small machine. It drives the same [`IndexingService`] the GUI +//! drives, so what it measures is the indexer's own footprint with no window, +//! no renderer and no GL context in the total. +//! +//! ```text +//! cargo build -p quicksearch-core --example memprobe --release +//! ./target/release/examples/memprobe cold /media/shared /var/tmp/qs-mem/index.db +//! ./target/release/examples/memprobe warm /media/shared /var/tmp/qs-mem/index.db +//! ./target/release/examples/memprobe cold /media/shared /var/tmp/qs-mem/index.db 10 +//! ``` +//! +//! The optional trailing number is the sampling interval in milliseconds +//! (default 100). Drop it to single digits to name the file a spike happened +//! on: at 100 ms the extractor has moved on by the time RSS is read, so the +//! file the timeline shows beside a spike is only approximately the cause. +//! +//! `cold` deletes the database first: every file is new, so the walk hashes +//! and extracts all of them and `existing_files` starts empty. `warm` re-runs +//! against the finished database, which is the case that loads one +//! `existing_files` entry per indexed path up front — the allocation that +//! scales with tree size rather than with in-flight work. +//! +//! Two peaks are reported and they measure different things: +//! +//! - **VmHWM** is the kernel's own high-water mark for resident set size. It +//! cannot miss a spike, so it is the number to quote. +//! - **sampled peak** comes from polling `/proc/self/statm`, and exists only +//! to say *when* the peak happened. The timeline it prints attributes the +//! peak to the walk or to extraction; a sampled peak far under VmHWM means +//! the real spike was shorter than the sampling interval. +//! +//! RSS counts the page cache backing the mmap'd database, so the figure is a +//! ceiling on what the process needs, not a floor on what it must have: those +//! pages are evictable under pressure. `/usr/bin/time -v` on this binary +//! reports the same VmHWM, as a cross-check that nothing here is fooling +//! itself. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use quicksearch_core::config::Config; +use quicksearch_core::indexing::{IndexingService, IndexingStatus, RootPhase}; + +/// Default RSS sampling interval. Cheap (one small `/proc` read), so this is +/// set by how fine-grained the timeline should be rather than by overhead. +const DEFAULT_SAMPLE_MS: u64 = 100; + +/// How often the completion marker is checked, in milliseconds. Rarer than +/// sampling because each check opens a connection to the database being +/// written, and a finer sampling interval must not turn into more of them. +const MARKER_INTERVAL_MS: u64 = 500; + +/// Wall-clock ceiling. A 100k-file tree indexes in minutes; anything past +/// this is a hang, and reporting a peak for a run that never finished would +/// be worse than failing. +const TIMEOUT: Duration = Duration::from_secs(3 * 3600); + +/// Resident bytes at the peak, grouped by what the mapping is. +/// +/// A peak figure alone cannot be acted on: 60 MiB of heap is a buffer to +/// size down, 60 MiB of file-backed pages is page cache the kernel will +/// drop under pressure, and 60 MiB of thread stacks is a pool that is too +/// wide. `smaps` is the only place that distinction is visible. +#[derive(Default, Clone)] +struct Breakdown { + entries: Vec<(String, u64)>, +} + +/// One RSS reading with the progress that produced it. +struct Sample { + at: Duration, + rss: u64, + walked: usize, + extracted: usize, + phase: &'static str, + /// What extraction was working on. A peak that a single file causes is + /// a different problem from one that grows with the tree, and this is + /// what tells the two apart. + file: String, +} + +fn main() { + let mut args = std::env::args().skip(1); + let mode = args.next().unwrap_or_default(); + let (Some(root), Some(db)) = (args.next(), args.next()) else { + eprintln!("usage: memprobe [sample_ms]"); + std::process::exit(2); + }; + if mode != "cold" && mode != "warm" { + eprintln!("usage: memprobe [sample_ms]"); + std::process::exit(2); + } + let interval = Duration::from_millis( + args.next() + .map(|s| s.parse().expect("sample_ms must be a number")) + .unwrap_or(DEFAULT_SAMPLE_MS) + .max(1), + ); + let db = PathBuf::from(db); + + if let Some(parent) = db.parent() { + std::fs::create_dir_all(parent).expect("create database directory"); + } + if mode == "cold" { + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{}", db.display(), suffix)); + } + } + + run(&mode, &root, &db, interval); +} + +fn run(mode: &str, root: &str, db: &Path, interval: Duration) { + let config = Config::default(); + + // Cleared for the same reason indexprobe clears it: the marker is the + // only unambiguous completion signal, and a stale one from the previous + // run would end this one immediately. + if db.exists() { + let conn = rusqlite::Connection::open(db).expect("open db"); + conn.execute("DELETE FROM schema_info WHERE key = 'last_full_index'", []) + .expect("clear marker"); + } + + let baseline = rss().expect("read /proc/self/statm"); + eprintln!( + "memprobe {}: root={} db={}\n baseline RSS {} (process before indexing starts)", + mode, + root, + db.display(), + mib(baseline) + ); + + let service = IndexingService::new(); + let start = Instant::now(); + service + .start_indexing( + vec![root.to_string()], + db.to_string_lossy().into_owned(), + config, + ) + .expect("start indexing"); + + let mut samples: Vec = Vec::new(); + let deadline = start + TIMEOUT; + let marker_every = (MARKER_INTERVAL_MS / interval.as_millis().max(1) as u64).max(1) as u32; + let mut ticks: u32 = 0; + let mut done = false; + let mut high = 0u64; + let mut at_peak = Breakdown::default(); + + while Instant::now() < deadline { + std::thread::sleep(interval); + ticks += 1; + + let status = service.get_status(); + if let IndexingStatus::Error(e) = &status { + panic!("indexing failed: {}", e); + } + let (walked, extracted, phase, file) = progress(&status); + let now = rss().unwrap_or(0); + // Only on a new high: reading smaps costs far more than statm, and + // the breakdown is only wanted for the sample that sets the peak. + if now > high { + high = now; + at_peak = breakdown(); + } + samples.push(Sample { + at: start.elapsed(), + rss: now, + walked, + extracted, + phase, + file, + }); + + if ticks % marker_every == 0 && db.exists() { + if let Ok(conn) = rusqlite::Connection::open(db) { + if quicksearch_core::db::repo::get_last_full_index(&conn).is_some() { + done = true; + break; + } + } + } + } + let elapsed = start.elapsed(); + + // Read before stopping: the peak belongs to the run, and stopping frees + // nothing that VmHWM would forget anyway. + let hwm = vm_hwm(); + assert!(done, "indexing did not finish within {:?}", TIMEOUT); + service.stop_indexing().expect("stop"); + + report(mode, elapsed, baseline, hwm, &samples, db, interval, &at_peak); +} + +/// Flatten per-root progress into one line's worth of numbers. Roots are +/// summed: the process has one address space, so a per-root split would not +/// explain a peak that several roots contribute to at once. +fn progress(status: &IndexingStatus) -> (usize, usize, &'static str, String) { + let IndexingStatus::Running { roots, .. } = status else { + return (0, 0, "-", String::new()); + }; + let walked = roots.iter().map(|r| r.walked).sum(); + let extracted = roots.iter().map(|r| r.extracted).sum(); + // The whole run's phase is the least-advanced root's: while any root is + // still walking, walk-sized allocations are still live. + let phase = if roots.iter().any(|r| r.phase == RootPhase::Walking) { + "walk" + } else if roots.iter().any(|r| r.phase == RootPhase::Extracting) { + "extract" + } else { + "done" + }; + let file = roots + .iter() + .find_map(|r| r.current_file.clone()) + .unwrap_or_default(); + (walked, extracted, phase, file) +} + +fn report( + mode: &str, + elapsed: Duration, + baseline: u64, + hwm: Option, + samples: &[Sample], + db: &Path, + interval: Duration, + at_peak: &Breakdown, +) { + // One line per 5% of the run, so the shape is visible at any duration. + let step = (samples.len() / 20).max(1); + eprintln!("\n {:>8} {:>10} {:>9} {:>10} {}", "t", "RSS", "walked", "extracted", "phase"); + for s in samples.iter().step_by(step) { + eprintln!( + " {:>7.1}s {:>10} {:>9} {:>10} {}", + s.at.as_secs_f64(), + mib(s.rss), + s.walked, + s.extracted, + s.phase + ); + } + + let peak = samples.iter().max_by_key(|s| s.rss); + // The maximum, not the last: progress reads zero again once the service + // returns to Idle, and the final sample is usually that one. + let files = samples.iter().map(|s| s.walked).max().unwrap_or(0); + let db_bytes = db_size(db); + + eprintln!("\n{} run: {:.1}s, {} files walked", mode, elapsed.as_secs_f64(), files); + match hwm { + Some(h) => eprintln!(" peak RSS (VmHWM) {}", mib(h)), + None => eprintln!(" peak RSS (VmHWM) unavailable"), + } + if let Some(p) = peak { + eprintln!( + " sampled peak {} at t={:.1}s during {} ({} walked, {} extracted){}", + mib(p.rss), + p.at.as_secs_f64(), + p.phase, + p.walked, + p.extracted, + if p.file.is_empty() { + String::new() + } else { + format!("\n on {}", p.file) + } + ); + } + eprintln!(" baseline RSS {}", mib(baseline)); + if let Some(h) = hwm { + if files > 0 { + eprintln!( + " growth per file {:.0} bytes ((peak - baseline) / files walked)", + h.saturating_sub(baseline) as f64 / files as f64 + ); + } + } + eprintln!(" database on disk {} (counts toward RSS as page cache)", mib(db_bytes)); + + if !at_peak.entries.is_empty() { + eprintln!("\n resident bytes at the peak, by mapping:"); + for (name, bytes) in at_peak.entries.iter().take(8) { + eprintln!(" {:>10} {}", mib(*bytes), name); + } + } + + // A transient spike is the failure mode a peak figure hides: steady-state + // use can be modest while one file briefly doubles it. Ranking the + // sample-to-sample rises names the files that do it. + let mut jumps: Vec<(u64, &Sample)> = samples + .windows(2) + .map(|w| (w[1].rss.saturating_sub(w[0].rss), &w[1])) + .filter(|(delta, _)| *delta > 4 * 1024 * 1024) + .collect(); + jumps.sort_by_key(|(delta, _)| std::cmp::Reverse(*delta)); + if !jumps.is_empty() { + eprintln!("\n largest RSS rises between samples ({:?} apart):", interval); + for (delta, s) in jumps.iter().take(8) { + eprintln!( + " +{:>9} to {:>10} at t={:>6.1}s {} {}", + mib(*delta), + mib(s.rss), + s.at.as_secs_f64(), + s.phase, + s.file + ); + } + } +} + +/// Resident set size now, from `/proc/self/statm` field 2 (resident pages). +fn rss() -> Option { + let statm = std::fs::read_to_string("/proc/self/statm").ok()?; + let pages: u64 = statm.split_whitespace().nth(1)?.parse().ok()?; + Some(pages * page_size()) +} + +/// Resident bytes per mapping from `/proc/self/smaps`, summed by name. +/// +/// The name is the mapping's path, or `[heap]`/`[stack]` for the ones the +/// kernel labels. Everything else is anonymous — thread stacks and any +/// large `malloc` that went to `mmap` rather than the main arena — and is +/// bucketed by size class, because individually they are unnamed and there +/// can be hundreds of them. +fn breakdown() -> Breakdown { + let Ok(smaps) = std::fs::read_to_string("/proc/self/smaps") else { + return Breakdown::default(); + }; + let mut by_name: std::collections::HashMap = std::collections::HashMap::new(); + let mut current = String::new(); + + for line in smaps.lines() { + if let Some(rss_kib) = line.strip_prefix("Rss:") { + let kib: u64 = rss_kib + .split_whitespace() + .next() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + *by_name.entry(current.clone()).or_default() += kib * 1024; + } else if let Some(header) = parse_map_header(line) { + current = header; + } + } + + let mut entries: Vec<(String, u64)> = by_name.into_iter().filter(|(_, b)| *b > 0).collect(); + entries.sort_by_key(|(_, bytes)| std::cmp::Reverse(*bytes)); + Breakdown { entries } +} + +/// The name for a `smaps` header line, or `None` if the line is not one. +/// +/// A header is `addr-addr perms offset dev inode [path]`. Anonymous mappings +/// have inode 0 and no path; they are bucketed by size so that a hundred +/// 8 MiB regions read as one line rather than a hundred. +fn parse_map_header(line: &str) -> Option { + let mut fields = line.split_whitespace(); + let range = fields.next()?; + let (lo, hi) = range.split_once('-')?; + let lo = u64::from_str_radix(lo, 16).ok()?; + let hi = u64::from_str_radix(hi, 16).ok()?; + // Fields 2-5 are perms, offset, dev, inode; anything after is the path. + let path = fields.nth(4).unwrap_or(""); + if !path.is_empty() { + return Some(path.to_string()); + } + Some(format!("anon {}", size_class(hi.saturating_sub(lo)))) +} + +/// Power-of-two bucket, so mappings group by what allocated them rather +/// than by their exact size. +fn size_class(bytes: u64) -> String { + let mib = bytes as f64 / (1024.0 * 1024.0); + if mib < 1.0 { + "< 1 MiB".to_string() + } else { + let bucket = 1u64 << (63 - (bytes / (1024 * 1024)).leading_zeros() as u64); + format!("~{} MiB", bucket) + } +} + +/// The kernel's peak RSS for this process, from `/proc/self/status`. +fn vm_hwm() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let line = status.lines().find(|l| l.starts_with("VmHWM:"))?; + let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?; + Some(kib * 1024) +} + +/// 4 KiB everywhere this runs. Reading it from `sysconf` would mean a libc +/// dependency for a constant that has never differed on the targets that +/// have `/proc`. +fn page_size() -> u64 { + 4096 +} + +/// The index plus its WAL: the WAL is where a run's writes sit until the +/// next checkpoint, so leaving it out understates a run in progress. +fn db_size(db: &Path) -> u64 { + ["", "-wal"] + .iter() + .filter_map(|s| std::fs::metadata(format!("{}{}", db.display(), s)).ok()) + .map(|m| m.len()) + .sum() +} + +fn mib(bytes: u64) -> String { + format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0)) +} diff --git a/crates/quicksearch-core/src/config.rs b/crates/quicksearch-core/src/config.rs index f88b567..d461824 100644 --- a/crates/quicksearch-core/src/config.rs +++ b/crates/quicksearch-core/src/config.rs @@ -24,6 +24,7 @@ pub struct Config { pub processing: ProcessingConfig, pub search: SearchConfig, pub ui: UiConfig, + pub security: SecurityConfig, /// File this config was loaded from; `save()` writes back to it. /// `None` for hand-built configs (tests), which save to the default /// location. @@ -193,6 +194,42 @@ impl Default for SearchConfig { } } +/// Index encryption. The password itself is never stored anywhere — only +/// the KDF salt lives here, and it is not a secret (it makes the derivation +/// unique per install, nothing more). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct SecurityConfig { + /// Encrypt the index (SQLCipher) with a password asked for at startup. + /// Turning this on or off requires deleting and rebuilding the index. + pub password_protected: bool, + /// KDF salt, exactly 32 lowercase hex digits (16 bytes). Written by the + /// app at the moment a password is set — never generated as a default, + /// never edited by hand, and never shown in the GUI. Absent until a + /// password exists. + #[serde(skip_serializing_if = "Option::is_none")] + pub salt: Option, + /// Store the derived key in the OS keychain (Secret Service / Windows + /// Credential Manager) and skip the startup prompt on this machine. + pub use_keychain: bool, +} + +impl SecurityConfig { + /// The decoded salt. An error means the config is unusable for + /// unlocking: protection is on but no salt was ever written, or the + /// value was tampered with — both surfaced to the user, never guessed + /// around. + pub fn salt_bytes(&self) -> Result<[u8; crate::security::SALT_LEN], String> { + match &self.salt { + None => Err("password protection is enabled but the config has no salt; \ + disable protection or set the password again" + .to_string()), + Some(hex) => crate::security::salt_from_hex(hex) + .map_err(|e| format!("invalid salt in config: {}", e)), + } + } +} + /// Interface preferences. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] @@ -227,6 +264,7 @@ impl Default for Config { processing: ProcessingConfig::default(), search: SearchConfig::default(), ui: UiConfig::default(), + security: SecurityConfig::default(), source: None, } } @@ -254,7 +292,7 @@ impl Default for Config { /// to catch it would also catch a user folder named `Windows`. /// `config_example.toml` documents it for people who add a drive root. fn default_ignore_patterns() -> Vec { - let mut patterns = vec![".git", "node_modules", "*.tmp", ".venv", "venv"]; + let mut patterns = vec![".git", "node_modules", "*.tmp", ".venv", "venv", "*.pdf"]; if cfg!(windows) { patterns.extend([ "$RECYCLE.BIN", @@ -601,11 +639,18 @@ pub fn nested_roots(roots: &[String]) -> Vec<(String, String)> { pub fn diff_actions(old: &Config, new: &Config) -> ConfigActions { let roots_changed = old.paths.indexing_paths != new.paths.indexing_paths; + // Encryption on↔off or a different salt (⇒ a different key) makes the + // on-disk file unreadable to the new configuration: rebuild. The GUI's + // security flows drive their own explicit rebuild dialog; this covers + // hand-edited configs applied through the generic path. `use_keychain` + // only changes where the key is remembered, not the file. let requires_rebuild = old.processing.hash_length != new.processing.hash_length || old.processing.tokenize != new.processing.tokenize || old.indexing.include_hidden != new.indexing.include_hidden || old.indexing.ignore_patterns != new.indexing.ignore_patterns || old.indexing.content_extensions != new.indexing.content_extensions + || old.security.password_protected != new.security.password_protected + || old.security.salt != new.security.salt || roots_changed; ConfigActions { requires_rebuild, @@ -952,6 +997,82 @@ mod tests { }, "soft knobs never force restarts" ); + + // Security: protection on↔off and salt changes rebuild; the + // keychain preference is a soft knob. + let mut c = base.clone(); + c.security.password_protected = true; + assert!(diff_actions(&base, &c).requires_rebuild); + let mut c = base.clone(); + c.security.salt = Some("00".repeat(16)); + assert!(diff_actions(&base, &c).requires_rebuild); + let mut c = base.clone(); + c.security.use_keychain = true; + assert!(!diff_actions(&base, &c).requires_rebuild); + } + + #[test] + fn security_config_round_trips_and_salt_is_omitted_when_none() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + + // Defaults: protection off, no salt — and crucially the file must + // not contain an invented salt value. + let cfg = Config::load_from(&path).unwrap(); + assert!(!cfg.security.password_protected); + assert_eq!(cfg.security.salt, None); + let text = fs::read_to_string(&path).unwrap(); + assert!(!text.contains("salt"), "no default salt may be written"); + + // With a salt set, it round-trips exactly. + let mut cfg = cfg; + cfg.security.password_protected = true; + cfg.security.salt = Some("0f1e2d3c4b5a69788796a5b4c3d2e1f0".to_string()); + cfg.security.use_keychain = true; + cfg.save().unwrap(); + let reloaded = Config::load_from(&path).unwrap(); + assert_eq!(reloaded.security, cfg.security); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn absent_security_section_is_default() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + fs::write(&path, "[paths]\ndatabase_path = \"x.sqlite\"\n").unwrap(); + let cfg = Config::load_from(&path).unwrap(); + assert_eq!(cfg.security, SecurityConfig::default()); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn salt_bytes_validates_hostile_configs() { + // Protected but no salt: hard error, nothing invented. + let mut sec = SecurityConfig { + password_protected: true, + salt: None, + use_keychain: false, + }; + assert!(sec.salt_bytes().is_err()); + + // Hand-crafted hostile values: truncated, oversized, non-hex, + // embedded whitespace/quotes. All rejected. + for bad in [ + "", + "abcd", + &"ab".repeat(17), + &"ab".repeat(4096), + "0g1e2d3c4b5a69788796a5b4c3d2e1f0", + "0f1e2d3c4b5a6978 796a5b4c3d2e1f0", + "0f1e2d3c4b5a69788796a5b4c3d2e1f'", + ] { + sec.salt = Some(bad.to_string()); + assert!(sec.salt_bytes().is_err(), "must reject salt {:?}", bad); + } + + // A valid salt decodes, upper- or lowercase. + sec.salt = Some("0F1E2D3C4B5A69788796A5B4C3D2E1F0".to_string()); + assert!(sec.salt_bytes().is_ok()); } /// Dismissing the watch-cap warning must not trigger a rebuild or a diff --git a/crates/quicksearch-core/src/db/key.rs b/crates/quicksearch-core/src/db/key.rs new file mode 100644 index 0000000..224c7e7 --- /dev/null +++ b/crates/quicksearch-core/src/db/key.rs @@ -0,0 +1,37 @@ +//! Process-global SQLCipher key. +//! +//! Exactly two processes ever open the index (the GUI and the one-shot +//! terminal search), and each resolves the key once at startup — before any +//! connection exists — then never changes it except when the GUI +//! enables/disables protection (which tears down and rebuilds the index +//! anyway). A set-once global therefore reaches all open sites, several of +//! which only hold a `&str` path, without threading a parameter through +//! every layer. + +use std::sync::RwLock; + +use crate::security::IndexKey; + +static PROCESS_KEY: RwLock> = RwLock::new(None); + +/// Install (or clear, with `None`) the key used by every subsequent +/// database open in this process. +pub fn set_process_key(key: Option) { + *PROCESS_KEY.write().expect("process key lock poisoned") = key; +} + +/// Snapshot of the current key for a single open. +pub(crate) fn process_key() -> Option { + PROCESS_KEY.read().expect("process key lock poisoned").clone() +} + +/// Hex form of the installed key, if any. Exists for exactly one consumer: +/// the GUI's "remember on this device" toggle, which stores the derived +/// key (never the password) in the OS keychain. +pub fn process_key_hex() -> Option { + PROCESS_KEY + .read() + .expect("process key lock poisoned") + .as_ref() + .map(|k| k.to_hex()) +} diff --git a/crates/quicksearch-core/src/db/mod.rs b/crates/quicksearch-core/src/db/mod.rs index 062be97..a3fd5af 100644 --- a/crates/quicksearch-core/src/db/mod.rs +++ b/crates/quicksearch-core/src/db/mod.rs @@ -8,8 +8,13 @@ //! size, `clear`) instead use [`open::open_existing`], which never creates or //! wipes — a tokenizer difference or stale version is an error, not data loss. +pub mod key; pub mod open; pub mod repo; pub mod schema; -pub use open::{open_existing, open_or_recreate, CURRENT_SCHEMA_VERSION}; +pub use key::{process_key_hex, set_process_key}; +pub use open::{ + open_existing, open_or_recreate, verify_process_key, CURRENT_SCHEMA_VERSION, + KEY_MISMATCH_PREFIX, +}; diff --git a/crates/quicksearch-core/src/db/open.rs b/crates/quicksearch-core/src/db/open.rs index 15db713..a8a3480 100644 --- a/crates/quicksearch-core/src/db/open.rs +++ b/crates/quicksearch-core/src/db/open.rs @@ -20,6 +20,13 @@ use rusqlite::{params, Connection, OpenFlags, OptionalExtension}; use super::schema::{ effective_tokenizer, fts_create_sql, PRAGMAS_FAST, PRAGMAS_READONLY, SCHEMA_CURRENT, }; +use crate::security::IndexKey; + +/// Prefix tagging every "the key doesn't fit this file" error. Callers use +/// it to tell a wrong password apart from real corruption or schema drift: +/// the GUI re-prompts, the CLI retries, and — critically — nothing treats +/// it as a reason to wipe or "recover" the database. +pub const KEY_MISMATCH_PREFIX: &str = "KEY_MISMATCH: "; /// Bump this whenever [`SCHEMA_CURRENT`] or [`fts_create_sql`] changes in /// a way that makes an old DB unreadable by new code. Any such bump @@ -35,6 +42,14 @@ pub const CURRENT_SCHEMA_VERSION: u32 = 3; /// `searchabletext`. Changing it against an existing DB counts as a /// schema mismatch and triggers the wipe-and-recreate path. pub fn open_or_recreate(db_path: &str, tokenizer: &str) -> Result { + open_or_recreate_keyed(db_path, tokenizer, super::key::process_key().as_ref()) +} + +pub(crate) fn open_or_recreate_keyed( + db_path: &str, + tokenizer: &str, + key: Option<&IndexKey>, +) -> Result { let path = Path::new(db_path).to_path_buf(); // The owner creates the directory too — a fresh install's default // XDG data dir doesn't exist until first use. @@ -46,6 +61,7 @@ pub fn open_or_recreate(db_path: &str, tokenizer: &str) -> Result Result Result Result { + open_existing_keyed(db_path, write, super::key::process_key().as_ref()) +} + +pub(crate) fn open_existing_keyed( + db_path: &str, + write: bool, + key: Option<&IndexKey>, +) -> Result { let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX | if write { OpenFlags::SQLITE_OPEN_READ_WRITE @@ -88,6 +112,7 @@ pub fn open_existing(db_path: &str, write: bool) -> Result { }; let conn = Connection::open_with_flags(db_path, flags) .map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?; + key_and_probe(&conn, db_path, key)?; let pragmas = if write { PRAGMAS_FAST } else { PRAGMAS_READONLY }; conn.execute_batch(pragmas) .map_err(|e| format!("Failed to apply pragmas: {}", e))?; @@ -102,6 +127,88 @@ pub fn open_existing(db_path: &str, write: bool) -> Result { Ok(conn) } +/// Cheaply check that the process key (or its absence) actually opens the +/// index. Used by the GUI unlock screen and the CLI prompt loop before any +/// service starts; the error carries [`KEY_MISMATCH_PREFIX`] on a wrong +/// password. +pub fn verify_process_key(db_path: &str) -> Result<(), String> { + open_existing(db_path, false).map(|_| ()) +} + +/// Apply the SQLCipher key (if any) and force the first page off disk. +/// +/// Ordering is load-bearing twice over: SQLCipher requires `PRAGMA key` +/// before anything else touches the file (our fast-path pragmas include +/// `journal_mode = WAL`, which reads the header), and the probe must run +/// before any schema comparison so that a wrong or missing key surfaces as +/// a tagged [`KEY_MISMATCH_PREFIX`] error — never as a "schema mismatch" +/// that [`open_or_recreate`] would answer by wiping the file. +/// +/// The raw-key `x'…'` form bypasses SQLCipher's per-connection PBKDF2 +/// (hundreds of ms); searches, status counts and duplicate scans each open +/// fresh connections, so the expensive KDF happens once at unlock, not per +/// open. +fn key_and_probe(conn: &Connection, db_path: &str, key: Option<&IndexKey>) -> Result<(), String> { + if let Some(key) = key { + // `cipher_log_level = NONE` mutes SQLCipher's own stderr logging + // (an HMAC-failure trace on every wrong-password attempt); the + // condition still surfaces through the API as SQLITE_NOTADB and is + // reported cleanly below. It must follow `PRAGMA key`, which has to + // be the first statement on the connection. + conn.execute_batch(&format!( + "PRAGMA key = \"x'{}'\"; PRAGMA cipher_log_level = NONE;", + key.to_hex() + )) + .map_err(|e| format!("Failed to apply encryption key: {}", e))?; + } + match conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| { + r.get::<_, i64>(0) + }) { + Ok(_) => Ok(()), + Err(e) if is_notadb(&e) => Err(key_mismatch_message(db_path, key.is_some())), + Err(e) => Err(format!("Failed to read database at {}: {}", db_path, e)), + } +} + +/// SQLITE_NOTADB is what an undecryptable first page looks like: with the +/// wrong key (or none) the decrypted header bytes are noise, and SQLite +/// reports "file is not a database". +fn is_notadb(e: &rusqlite::Error) -> bool { + matches!( + e, + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::NotADatabase, + .. + }, + _, + ) + ) +} + +fn key_mismatch_message(db_path: &str, had_key: bool) -> String { + // An unencrypted SQLite file still has its plaintext magic; sniffing it + // distinguishes "wrong password" from "protection is enabled but the + // index was never encrypted" (e.g. a crash between saving the config + // and rebuilding the index). + let plaintext = std::fs::File::open(db_path) + .ok() + .and_then(|mut f| { + use std::io::Read; + let mut magic = [0u8; 16]; + f.read_exact(&mut magic).ok()?; + Some(&magic == b"SQLite format 3\0") + }) + .unwrap_or(false); + let detail = match (had_key, plaintext) { + (true, true) => "password protection is enabled but the index is not encrypted; \ + rebuild the index to encrypt it", + (true, false) => "wrong password (or the file is not a QuickSearch index)", + (false, _) => "the index is password-protected; a password is required", + }; + format!("{}index at {}: {}", KEY_MISMATCH_PREFIX, db_path, detail) +} + /// True iff the DB has a `schema_info` table whose `version` equals /// [`CURRENT_SCHEMA_VERSION`]. Shared by the wipe decision /// ([`db_matches_current`]) and the non-destructive [`open_existing`] path. @@ -152,8 +259,14 @@ fn db_matches_current(conn: &Connection, tokenizer: &str) -> Result Result { +/// sidecars, reopen a fresh file, re-apply key and pragmas. Re-keying here +/// is essential: a rebuild of a protected index must come back encrypted, +/// never silently plaintext. +fn wipe_and_reopen( + conn: Connection, + path: &Path, + key: Option<&IndexKey>, +) -> Result { drop(conn); // Primary file may already be absent (fresh open that just needed // the table applied). Ignore NotFound; anything else is an error. @@ -185,6 +298,7 @@ fn wipe_and_reopen(conn: Connection, path: &Path) -> Result } let conn = Connection::open(path) .map_err(|e| format!("Failed to reopen database after rebuild: {}", e))?; + key_and_probe(&conn, &path.to_string_lossy(), key)?; conn.execute_batch(PRAGMAS_FAST) .map_err(|e| format!("Failed to apply pragmas after rebuild: {}", e))?; Ok(conn) @@ -533,6 +647,167 @@ mod tests { std::fs::remove_file(&p).ok(); } + fn test_key(seed: u8) -> IndexKey { + IndexKey::from_hex(&format!("{:02x}", seed).repeat(32)).unwrap() + } + + fn file_bytes(p: &Path) -> Vec { + std::fs::read(p).unwrap() + } + + #[test] + fn keyed_create_reopen_and_header_is_encrypted() { + let p = tmp_db_path(); + let key = test_key(0xa1); + { + let conn = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&key)).unwrap(); + conn.execute( + "INSERT INTO files (name, path, parent, size, mtime) \ + VALUES ('secret', '/secret.txt', '/', 0, 0)", + [], + ) + .unwrap(); + } + // Encrypted at rest: the plaintext SQLite magic must be gone. + let head = &file_bytes(&p)[..16]; + assert_ne!(head, b"SQLite format 3\0", "file must not be plaintext"); + + // Reopens with the same key, both owner and consumer paths. + { + let conn = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&key)).unwrap(); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 1, "keyed reopen must see existing rows, not wipe"); + } + let conn = open_existing_keyed(p.to_str().unwrap(), false, Some(&key)).unwrap(); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 1); + drop(conn); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn wrong_key_errors_without_wiping() { + let p = tmp_db_path(); + { + let conn = + open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&test_key(0xa1))) + .unwrap(); + conn.execute( + "INSERT INTO files (name, path, parent, size, mtime) \ + VALUES ('x', '/x', '/', 0, 0)", + [], + ) + .unwrap(); + } + let before = file_bytes(&p); + for write in [false, true] { + let err = open_existing_keyed(p.to_str().unwrap(), write, Some(&test_key(0xb2))) + .unwrap_err(); + assert!(err.starts_with(KEY_MISMATCH_PREFIX), "got: {err}"); + } + // The owner path must error too — a wrong key is never a "schema + // mismatch" to answer with a wipe. + let err = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&test_key(0xb2))) + .unwrap_err(); + assert!(err.starts_with(KEY_MISMATCH_PREFIX), "got: {err}"); + assert_eq!(before, file_bytes(&p), "file must be byte-identical"); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn missing_key_on_encrypted_db_errors_without_wiping() { + let p = tmp_db_path(); + { + let _ = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&test_key(0xa1))) + .unwrap(); + } + let before = file_bytes(&p); + let err = open_existing_keyed(p.to_str().unwrap(), false, None).unwrap_err(); + assert!(err.starts_with(KEY_MISMATCH_PREFIX), "got: {err}"); + assert!(err.contains("password-protected"), "got: {err}"); + let err = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", None).unwrap_err(); + assert!(err.starts_with(KEY_MISMATCH_PREFIX), "got: {err}"); + assert_eq!(before, file_bytes(&p)); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn key_on_plaintext_db_errors_without_wiping() { + let p = tmp_db_path(); + { + let _ = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap(); + } + let before = file_bytes(&p); + let err = + open_existing_keyed(p.to_str().unwrap(), false, Some(&test_key(0xa1))).unwrap_err(); + assert!(err.starts_with(KEY_MISMATCH_PREFIX), "got: {err}"); + // The sniffed plaintext header yields the precise diagnosis. + assert!(err.contains("not encrypted"), "got: {err}"); + let err = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&test_key(0xa1))) + .unwrap_err(); + assert!(err.starts_with(KEY_MISMATCH_PREFIX), "got: {err}"); + assert_eq!(before, file_bytes(&p)); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn schema_mismatch_under_key_wipes_and_recreates_encrypted() { + // The one case where the owner *should* still wipe: right key, + // stale schema. The replacement must come back encrypted. + let p = tmp_db_path(); + let key = test_key(0xa1); + { + let conn = Connection::open(&p).unwrap(); + conn.execute_batch(&format!("PRAGMA key = \"x'{}'\";", key.to_hex())) + .unwrap(); + conn.execute( + "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value TEXT NOT NULL)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO schema_info(key,value) VALUES('version','1')", + [], + ) + .unwrap(); + } + let conn = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&key)).unwrap(); + let v: String = conn + .query_row( + "SELECT value FROM schema_info WHERE key='version'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(v, CURRENT_SCHEMA_VERSION.to_string()); + drop(conn); + let head = &file_bytes(&p)[..16]; + assert_ne!( + head, b"SQLite format 3\0", + "rebuilt index must still be encrypted" + ); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn garbage_file_with_key_reports_mismatch_not_corruption() { + // A maliciously or accidentally replaced index file: random bytes, + // no SQLite header. Must surface as KEY_MISMATCH (it is + // indistinguishable from a wrong key), never wipe. + let p = tmp_db_path(); + std::fs::write(&p, [0x5a; 4096]).unwrap(); + let before = file_bytes(&p); + let err = + open_existing_keyed(p.to_str().unwrap(), false, Some(&test_key(0xa1))).unwrap_err(); + assert!(err.starts_with(KEY_MISMATCH_PREFIX), "got: {err}"); + assert_eq!(before, file_bytes(&p)); + std::fs::remove_file(&p).ok(); + } + #[test] fn open_existing_rw_allows_delete() { let p = tmp_db_path(); diff --git a/crates/quicksearch-core/src/file_handling.rs b/crates/quicksearch-core/src/file_handling.rs index 21e7a9a..51dbcef 100644 --- a/crates/quicksearch-core/src/file_handling.rs +++ b/crates/quicksearch-core/src/file_handling.rs @@ -32,7 +32,8 @@ pub fn load_existing_files(conn: &Connection) -> Result(0)?, ExistingFileEntry { - mtime: row.get(1)?, + // SQLite stores i64; mtimes are non-negative in practice. + mtime: row.get::<_, i64>(1)?.max(0) as u64, }, )) })?; @@ -992,7 +993,7 @@ pub fn extract_scope_prepare( cursor: &ExtractCursor, config: &Config, ) -> Result { - let max_size = config.processing.maximum_text_file_size; + let max_size = i64::try_from(config.processing.maximum_text_file_size).unwrap_or(i64::MAX); let conn = conn_mutex.lock().unwrap(); conn.execute( "UPDATE files SET content_state = 3 \ @@ -1039,7 +1040,7 @@ pub fn extract_one_batch( if should_abort(stop_flag, suspend_flag) { return Ok(0); } - let max_size = config.processing.maximum_text_file_size; + let max_size = i64::try_from(config.processing.maximum_text_file_size).unwrap_or(i64::MAX); let batch_limit = config.processing.batch_size.max(1) as i64; let batch: Vec<(i64, String, String, Option)> = { diff --git a/crates/quicksearch-core/src/lib.rs b/crates/quicksearch-core/src/lib.rs index 88304b2..bfc28cc 100644 --- a/crates/quicksearch-core/src/lib.rs +++ b/crates/quicksearch-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod mime; pub mod platform; pub mod query; pub mod search; +pub mod security; pub mod shutdown; pub mod snippet; pub mod walk; diff --git a/crates/quicksearch-core/src/search/mod.rs b/crates/quicksearch-core/src/search/mod.rs index ad7c30e..7d94ae5 100644 --- a/crates/quicksearch-core/src/search/mod.rs +++ b/crates/quicksearch-core/src/search/mod.rs @@ -203,7 +203,12 @@ impl SearchService { /// Map SQLite-level errors to the tagged strings frontends key off. /// `DATABASE_CORRUPTED:` drives the GUI's recovery dialog. pub fn classify_sql_err(error_msg: &str) -> String { - if error_msg.contains("malformed") + if error_msg.starts_with(db::KEY_MISMATCH_PREFIX) { + // Wrong or missing encryption key. Already user-legible, and it + // must never fall into the corruption bucket — the recovery dialog + // would offer to delete an index that is perfectly intact. + error_msg.to_string() + } else if error_msg.contains("malformed") || error_msg.contains("corrupt") || error_msg.contains("database disk image is malformed") { @@ -305,3 +310,34 @@ impl Worker { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_mismatch_is_never_classified_as_corruption() { + let msg = format!( + "{}index at /tmp/x.sqlite: wrong password (or the file is not a QuickSearch index)", + db::KEY_MISMATCH_PREFIX + ); + let classified = classify_sql_err(&msg); + assert_eq!(classified, msg, "must pass through verbatim"); + assert!(!classified.starts_with("DATABASE_CORRUPTED:")); + + // The raw SQLite wording for an undecryptable page must not land in + // the corruption bucket either — the GUI recovery dialog offers to + // delete the file, which is exactly wrong for an intact encrypted + // index. (It doesn't match the corruption needles; pin that.) + let raw = "Failed to read database at /tmp/x.sqlite: file is not a database"; + assert!(!classify_sql_err(raw).starts_with("DATABASE_CORRUPTED:")); + } + + #[test] + fn corruption_and_syntax_classification_still_work() { + assert!(classify_sql_err("database disk image is malformed") + .starts_with("DATABASE_CORRUPTED:")); + assert!(classify_sql_err("fts5: syntax error near \"NEAR\"").starts_with("Search syntax")); + assert!(classify_sql_err("no such table: files").starts_with("Search failed:")); + } +} diff --git a/crates/quicksearch-core/src/security.rs b/crates/quicksearch-core/src/security.rs new file mode 100644 index 0000000..b2d0202 --- /dev/null +++ b/crates/quicksearch-core/src/security.rs @@ -0,0 +1,205 @@ +//! Password → SQLCipher key derivation for the optional index encryption. +//! +//! The chain is deliberately small: `key = Argon2id(password, salt)`, used +//! directly as the SQLCipher raw key. The salt is 16 random bytes generated +//! once, at the moment a password is set, and stored as hex in the config +//! file — it exists to make the derivation unique per install, not to be +//! secret. Argon2id is what makes offline brute-force of the password +//! expensive; SQLCipher's own KDF is bypassed (raw-key form) because we +//! open a fresh connection per search request and cannot afford a KDF per +//! open. +//! +//! Callers own password hygiene: hold the raw password in a +//! [`zeroize::Zeroizing`] buffer, call [`derive_key`], and drop the buffer +//! immediately. Nothing in this module stores or logs the password. + +use argon2::{Algorithm, Argon2, Params, Version}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Length in bytes of the per-install KDF salt stored (hex) in the config. +pub const SALT_LEN: usize = 16; +/// Length in bytes of the derived SQLCipher raw key. +pub const KEY_LEN: usize = 32; + +/// Argon2id cost parameters. Changing any of these changes every derived +/// key, which makes every protected index unreadable — treat them like a +/// schema version. ~0.5 s on desktop hardware; paid once per unlock, never +/// per connection (the keychain path skips it entirely). +const ARGON2_MEM_KIB: u32 = 64 * 1024; +const ARGON2_ITERS: u32 = 3; +const ARGON2_LANES: u32 = 1; + +/// The derived SQLCipher key. Zeroed on drop; `Debug` is redacted so it can +/// never leak through logs or error formatting. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub struct IndexKey([u8; KEY_LEN]); + +impl std::fmt::Debug for IndexKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("IndexKey()") + } +} + +impl IndexKey { + /// Lowercase hex, suitable for SQLCipher's raw-key `PRAGMA key = "x'…'"` + /// form and for keychain storage. + pub fn to_hex(&self) -> String { + hex_encode(&self.0) + } + + /// Strict inverse of [`to_hex`]: exactly 64 hex digits, any case. + pub fn from_hex(hex: &str) -> Result { + let bytes = hex_decode(hex)?; + let arr: [u8; KEY_LEN] = bytes + .try_into() + .map_err(|_| format!("key must be {} hex digits", KEY_LEN * 2))?; + Ok(IndexKey(arr)) + } +} + +/// Fresh random salt. Called only when a password is being set — a salt is +/// never invented anywhere else (no default exists). +pub fn generate_salt() -> [u8; SALT_LEN] { + let mut salt = [0u8; SALT_LEN]; + getrandom::getrandom(&mut salt).expect("OS randomness unavailable"); + salt +} + +pub fn salt_to_hex(salt: &[u8; SALT_LEN]) -> String { + hex_encode(salt) +} + +/// Strict decode of a config-stored salt: exactly 32 hex digits. Anything +/// else — wrong length, non-hex bytes, a hand-crafted oversized value — is +/// an error, never silently truncated or padded. +pub fn salt_from_hex(hex: &str) -> Result<[u8; SALT_LEN], String> { + let bytes = hex_decode(hex)?; + bytes + .try_into() + .map_err(|_| format!("salt must be {} hex digits", SALT_LEN * 2)) +} + +/// Derive the SQLCipher key from a password and the per-install salt. +/// Deterministic: same inputs always yield the same key. +pub fn derive_key(password: &str, salt: &[u8; SALT_LEN]) -> IndexKey { + let params = Params::new(ARGON2_MEM_KIB, ARGON2_ITERS, ARGON2_LANES, Some(KEY_LEN)) + .expect("static Argon2 params are valid"); + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut out = [0u8; KEY_LEN]; + argon + .hash_password_into(password.as_bytes(), salt, &mut out) + .expect("Argon2 accepts any password with a fixed-size salt"); + IndexKey(out) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + use std::fmt::Write; + write!(s, "{:02x}", b).expect("writing to a String cannot fail"); + } + s +} + +fn hex_decode(hex: &str) -> Result, String> { + if hex.len() % 2 != 0 { + return Err("hex string has odd length".to_string()); + } + if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("hex string contains non-hex characters".to_string()); + } + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Golden vector: pins password + salt → key. If this test breaks, the + /// derivation changed and every existing protected index just became + /// unreadable — that must never happen by accident. + #[test] + fn derive_key_is_pinned() { + let salt = salt_from_hex("000102030405060708090a0b0c0d0e0f").unwrap(); + let key = derive_key("correct horse battery staple", &salt); + assert_eq!( + key.to_hex(), + "0d1a3c6523c8f06e4e0af9c515aa5b5448cfebd6838f2d52c3d8b6ef8ddc3c2e" + ); + } + + #[test] + fn derive_key_is_deterministic_and_salt_sensitive() { + let salt_a = salt_from_hex("000102030405060708090a0b0c0d0e0f").unwrap(); + let salt_b = salt_from_hex("ffffffffffffffffffffffffffffffff").unwrap(); + assert_eq!(derive_key("pw", &salt_a), derive_key("pw", &salt_a)); + assert_ne!(derive_key("pw", &salt_a), derive_key("pw", &salt_b)); + assert_ne!(derive_key("pw", &salt_a), derive_key("pw2", &salt_a)); + } + + #[test] + fn empty_and_unicode_passwords_derive() { + // SQLCipher accepts any 32-byte key; the password's content is the + // user's business, including empty or emoji. + let salt = generate_salt(); + let _ = derive_key("", &salt); + let _ = derive_key("på55wörd 🗝️", &salt); + } + + #[test] + fn generated_salts_are_unique_and_sized() { + let a = generate_salt(); + let b = generate_salt(); + assert_eq!(a.len(), SALT_LEN); + assert_ne!(a, b, "two fresh salts must not collide"); + } + + #[test] + fn salt_hex_round_trips() { + let salt = generate_salt(); + let hex = salt_to_hex(&salt); + assert_eq!(hex.len(), SALT_LEN * 2); + assert_eq!(salt_from_hex(&hex).unwrap(), salt); + // Uppercase input decodes too. + assert_eq!(salt_from_hex(&hex.to_uppercase()).unwrap(), salt); + } + + #[test] + fn malformed_salts_are_rejected() { + // Too short / too long / odd length / non-hex — all hostile-config + // shapes, all hard errors. + assert!(salt_from_hex("").is_err()); + assert!(salt_from_hex("abcd").is_err()); + assert!(salt_from_hex(&"ab".repeat(SALT_LEN + 1)).is_err()); + assert!(salt_from_hex(&"ab".repeat(SALT_LEN * 64)).is_err()); + assert!(salt_from_hex("0g0102030405060708090a0b0c0d0e0f").is_err()); + assert!(salt_from_hex("00010203040506070809 a0b0c0d0e0f").is_err()); + let odd = "000102030405060708090a0b0c0d0e0"; + assert!(salt_from_hex(odd).is_err()); + } + + #[test] + fn key_hex_round_trips_and_rejects_malformed() { + let salt = generate_salt(); + let key = derive_key("pw", &salt); + let hex = key.to_hex(); + assert_eq!(hex.len(), KEY_LEN * 2); + assert_eq!(IndexKey::from_hex(&hex).unwrap(), key); + assert!(IndexKey::from_hex("").is_err()); + assert!(IndexKey::from_hex("abcd").is_err()); + assert!(IndexKey::from_hex(&"ab".repeat(KEY_LEN + 1)).is_err()); + assert!(IndexKey::from_hex(&hex[..hex.len() - 1]).is_err()); + assert!(IndexKey::from_hex(&format!("zz{}", &hex[2..])).is_err()); + } + + #[test] + fn debug_output_is_redacted() { + let key = derive_key("secret", &generate_salt()); + let dbg = format!("{:?}", key); + assert_eq!(dbg, "IndexKey()"); + assert!(!dbg.contains(&key.to_hex())); + } +} diff --git a/crates/quicksearch-core/tests/encrypted.rs b/crates/quicksearch-core/tests/encrypted.rs new file mode 100644 index 0000000..610ff09 --- /dev/null +++ b/crates/quicksearch-core/tests/encrypted.rs @@ -0,0 +1,144 @@ +//! End-to-end index encryption through the public API: the process-global +//! key, a real indexing run over a real tree, and the enable→disable +//! rebuild cycle. +//! +//! Lives in its own integration-test binary on purpose: it mutates the +//! process-global key, which unit tests (sharing one process) must never +//! do. Everything runs inside a single #[test] so the key transitions are +//! strictly ordered. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use quicksearch_core::config::Config; +use quicksearch_core::db; +use quicksearch_core::indexing::{IndexingService, IndexingStatus}; +use quicksearch_core::security::{derive_key, salt_from_hex}; + +fn tmp_dir(tag: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "quicksearch-enc-{}-{}-{}", + tag, + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&p).unwrap(); + p +} + +/// Run one full index over `root` and wait for the completion marker, +/// reading it through the keyed open so the poll works on encrypted DBs. +fn index_once(root: &Path, db_path: &Path, config: &Config) { + let service = IndexingService::new(); + service + .start_indexing( + vec![root.to_string_lossy().into_owned()], + db_path.to_string_lossy().into_owned(), + config.clone(), + ) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(120); + let mut done = false; + while Instant::now() < deadline { + if let IndexingStatus::Error(e) = service.get_status() { + panic!("indexing failed: {}", e); + } + if db_path.exists() { + if let Ok(conn) = db::open_existing(&db_path.to_string_lossy(), false) { + if quicksearch_core::db::repo::get_last_full_index(&conn).is_some() { + done = true; + break; + } + } + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(done, "indexing did not finish within the timeout"); + service.stop_indexing().unwrap(); +} + +fn header(db_path: &Path) -> [u8; 16] { + let bytes = std::fs::read(db_path).unwrap(); + bytes[..16].try_into().unwrap() +} + +fn match_count(db_path: &Path, term: &str) -> i64 { + let conn = db::open_existing(&db_path.to_string_lossy(), false).unwrap(); + conn.query_row( + "SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH ?1", + [term], + |r| r.get(0), + ) + .unwrap() +} + +#[test] +fn encrypted_index_lifecycle() { + let root = tmp_dir("tree"); + let data = tmp_dir("db"); + let db_path = data.join("index.sqlite"); + std::fs::write(root.join("note.txt"), "the zebrapayload roams the index").unwrap(); + std::fs::write(root.join("other.txt"), "unrelated content here").unwrap(); + + let config = Config::default(); + let salt = salt_from_hex("00112233445566778899aabbccddeeff").unwrap(); + let key = derive_key("hunter2", &salt); + let wrong_key = derive_key("hunter3", &salt); + + // --- Enabled: index is created encrypted and searchable. --- + db::set_process_key(Some(key.clone())); + index_once(&root, &db_path, &config); + assert_ne!( + &header(&db_path), + b"SQLite format 3\0", + "protected index must not carry the plaintext SQLite header" + ); + assert_eq!(match_count(&db_path, "zebrapayload"), 1); + + // Raw bytes must not leak the indexed content anywhere in the file. + let raw = std::fs::read(&db_path).unwrap(); + assert!( + !raw.windows(b"zebrapayload".len()).any(|w| w == b"zebrapayload"), + "plaintext content leaked into the encrypted file" + ); + + // --- Wrong password / no password: tagged error, file intact. --- + let before = std::fs::read(&db_path).unwrap(); + db::set_process_key(Some(wrong_key)); + let err = db::verify_process_key(&db_path.to_string_lossy()).unwrap_err(); + assert!(err.starts_with(db::KEY_MISMATCH_PREFIX), "got: {err}"); + db::set_process_key(None); + let err = db::verify_process_key(&db_path.to_string_lossy()).unwrap_err(); + assert!(err.starts_with(db::KEY_MISMATCH_PREFIX), "got: {err}"); + assert_eq!( + before, + std::fs::read(&db_path).unwrap(), + "failed unlocks must never modify the index" + ); + + // --- Disable: delete + rebuild produces a plaintext index. --- + let service = IndexingService::new(); + service + .delete_index_for_rebuild(&db_path.to_string_lossy()) + .unwrap(); + assert!(!db_path.exists()); + index_once(&root, &db_path, &config); + assert_eq!(&header(&db_path), b"SQLite format 3\0"); + assert_eq!(match_count(&db_path, "zebrapayload"), 1); + + // The old key no longer opens it, with the precise "not encrypted" + // diagnosis (the crash-between-config-save-and-rebuild scenario). + db::set_process_key(Some(key)); + let err = db::verify_process_key(&db_path.to_string_lossy()).unwrap_err(); + assert!(err.starts_with(db::KEY_MISMATCH_PREFIX), "got: {err}"); + assert!(err.contains("not encrypted"), "got: {err}"); + + db::set_process_key(None); + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&data).ok(); +} diff --git a/crates/quicksearch-gui/Cargo.toml b/crates/quicksearch-gui/Cargo.toml index bdf7774..4e1d1bd 100644 --- a/crates/quicksearch-gui/Cargo.toml +++ b/crates/quicksearch-gui/Cargo.toml @@ -24,6 +24,15 @@ path = "src/cli_main.rs" [dependencies] quicksearch-core = { path = "../quicksearch-core" } +# Index-password support: OS keychain for "remember on this device" +# (Secret Service on Linux, Credential Manager on Windows), hidden terminal +# prompt, and best-effort scrubbing of password buffers. +# `vendored` compiles libdbus statically, so neither the build machine nor +# the .deb needs a dbus development/runtime package. +keyring = { version = "3", features = ["sync-secret-service", "vendored", "windows-native"] } +rpassword = "7" +zeroize = "1" + eframe = { version = "0.32", default-features = false, features = [ "glow", "persistence", diff --git a/crates/quicksearch-gui/src/app.rs b/crates/quicksearch-gui/src/app.rs index f944b21..6c76b76 100644 --- a/crates/quicksearch-gui/src/app.rs +++ b/crates/quicksearch-gui/src/app.rs @@ -1,21 +1,26 @@ //! Application shell: tab strip, per-frame event drains, debounce, //! status bar, and config-change routing. +use std::sync::mpsc; use std::time::{Duration, Instant}; use quicksearch_core::cli::{index_counts, IndexCounts}; -use quicksearch_core::config::{diff_actions, nested_roots, Config}; +use quicksearch_core::config::{diff_actions, nested_roots, Config, SecurityConfig}; use quicksearch_core::coordinator::{IndexMode, IndexerState, WatcherStatus}; +use quicksearch_core::db; use quicksearch_core::indexing::{ConfigChange, IndexingStatus, RootPhase}; use quicksearch_core::search::SearchOptions; +use quicksearch_core::security::{derive_key, generate_salt, salt_to_hex, IndexKey}; use quicksearch_core::watcher::WatchError; +use zeroize::{Zeroize, Zeroizing}; use crate::backend::Backend; use crate::duplicates_tab::{DupState, DuplicatesTab}; use crate::format::{fmt_interval, group_thousands}; +use crate::keychain; use crate::logs_tab::LogsTab; use crate::manage_tab::ManageTab; -use crate::options::OptionsWindow; +use crate::options::{OptionsWindow, SecurityAction}; use crate::search_tab::SearchTab; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -24,6 +29,7 @@ enum Tab { Manage, Duplicates, Logs, + Help, } pub struct QuickSearchApp { @@ -47,28 +53,62 @@ pub struct QuickSearchApp { /// Set when the watcher gave up on the directory budget and live /// updates are off; see [`QuickSearchApp::check_watch_cap_warning`]. watch_cap_prompt: Option, + /// In-flight security flow (enable/disable/change password), driven by + /// the Options window's Security block. + security_prompt: Option, config_error: Option, } +/// The two-step security flow: collect a password (enable/change), derive +/// its key off the UI thread, then confirm the mandatory index rebuild. +/// Disabling skips straight to the confirmation. +enum SecurityPrompt { + SetPassword { + pw1: String, + pw2: String, + remember: bool, + change: bool, + }, + Deriving { + rx: mpsc::Receiver<(SecurityConfig, IndexKey)>, + }, + ConfirmRebuild { + new_security: SecurityConfig, + new_key: Option, + }, +} + +impl Drop for SecurityPrompt { + fn drop(&mut self) { + if let SecurityPrompt::SetPassword { pw1, pw2, .. } = self { + pw1.zeroize(); + pw2.zeroize(); + } + } +} + impl QuickSearchApp { /// `initial_query` pre-fills the search box and fires a search on the /// first frame. It carries the positional arguments the binary was given, /// which on Windows is the only thing the GUI can do with them — terminal /// output belongs to `quicksearch-cli` there. + /// Takes a plain [`egui::Context`] rather than eframe's + /// `CreationContext` because construction can happen mid-session: the + /// unlock gate builds the app only after the password verifies. pub fn new( - cc: &eframe::CreationContext<'_>, + ctx: &egui::Context, cfg: Config, config_error: Option, initial_query: Option, ) -> Result { // Compact styling: results density is the whole point. - cc.egui_ctx.style_mut(|style| { + ctx.style_mut(|style| { style.spacing.item_spacing = egui::vec2(6.0, 3.0); style.spacing.button_padding = egui::vec2(6.0, 2.0); }); - cc.egui_ctx.set_zoom_factor(clamp_scale(cfg.ui.scale)); + ctx.set_zoom_factor(clamp_scale(cfg.ui.scale)); - let backend = Backend::start(&cfg, cc.egui_ctx.clone())?; + let backend = Backend::start(&cfg, ctx.clone())?; let fuzzy = cfg.search.fuzzy_default; // Startup validation: a hand-edited config can nest roots, which // per-root pipelines can't accept. Redirect straight to the folder @@ -98,6 +138,7 @@ impl QuickSearchApp { clear_prompt: false, nested_prompt, watch_cap_prompt: None, + security_prompt: None, config_error, }) } @@ -128,6 +169,11 @@ impl QuickSearchApp { /// Save + route an edited config to the running services. fn apply_new_config(&mut self, ctx: &egui::Context, mut new: Config) { + // The security section is never edited through config drafts — it + // changes only via the explicit flows in `handle_security_action`. + // Pinning it here keeps a stale draft (taken before a security + // change) from silently reverting protection or the salt. + new.security = self.cfg.security.clone(); if let Some((child, parent)) = nested_roots(&new.paths.indexing_paths).first() { self.config_error = Some(format!( "Not applied: indexed folder {} is nested under {}", @@ -184,7 +230,6 @@ impl QuickSearchApp { } } self.cfg = new; - self.manage.invalidate_editors(); } fn drain_events(&mut self) { @@ -444,6 +489,247 @@ impl QuickSearchApp { self.rebuild_prompt = None; } } + + /// Route a click in the Options window's Security block. Keychain + /// toggles act immediately; everything else opens the two-step flow. + fn handle_security_action(&mut self, action: SecurityAction) { + match action { + SecurityAction::Enable | SecurityAction::ChangePassword => { + self.security_prompt = Some(SecurityPrompt::SetPassword { + pw1: String::new(), + pw2: String::new(), + remember: self.cfg.security.use_keychain, + change: matches!(action, SecurityAction::ChangePassword), + }); + } + SecurityAction::Disable => { + self.security_prompt = Some(SecurityPrompt::ConfirmRebuild { + new_security: SecurityConfig::default(), + new_key: None, + }); + } + SecurityAction::SetKeychain(remember) => { + let db_path = self.cfg.resolved_database_path(); + if remember { + match db::process_key_hex() { + Some(hex) => { + if let Err(e) = keychain::store_key(&db_path.to_string_lossy(), &hex) + { + self.config_error = Some(e); + return; // preference not saved either + } + } + None => { + // Unreachable while protected — the gate always + // installs a key before the app starts. + self.config_error = + Some("no key to remember; restart and unlock first".to_string()); + return; + } + } + } else { + keychain::delete_key(&db_path.to_string_lossy()); + } + self.cfg.security.use_keychain = remember; + if let Err(e) = self.cfg.save() { + self.config_error = Some(e); + } + } + } + } + + /// Render the active security flow (drawn with the other modals). + fn security_prompt_ui(&mut self, ctx: &egui::Context) { + let Some(prompt) = &mut self.security_prompt else { + return; + }; + match prompt { + SecurityPrompt::SetPassword { + pw1, + pw2, + remember, + change, + } => { + let title = if *change { + "Change password" + } else { + "Enable password protection" + }; + let mut submit = false; + let mut cancel = false; + egui::Window::new(title) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(360.0); + ui.add( + egui::TextEdit::singleline(pw1) + .id(egui::Id::new("security-pw1")) + .password(true) + .hint_text("Password") + .desired_width(240.0), + ); + ui.add( + egui::TextEdit::singleline(pw2) + .id(egui::Id::new("security-pw2")) + .password(true) + .hint_text("Confirm password") + .desired_width(240.0), + ); + ui.checkbox(remember, "Remember on this device").on_hover_text( + "Stores the derived key (not the password) in the OS \ + keychain and skips the startup prompt.", + ); + if !pw1.is_empty() && !pw2.is_empty() && pw1 != pw2 { + ui.colored_label(ui.visuals().error_fg_color, "Passwords do not match."); + } + ui.horizontal(|ui| { + let ok = !pw1.is_empty() && pw1 == pw2; + if ui.add_enabled(ok, egui::Button::new("Continue")).clicked() { + submit = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + if cancel { + self.security_prompt = None; // Drop impl zeroizes + purge_security_field_state(ctx); + } else if submit { + let password = Zeroizing::new(std::mem::take(pw1)); + pw2.zeroize(); + let remember = *remember; + purge_security_field_state(ctx); + let (tx, rx) = mpsc::channel(); + let repaint = ctx.clone(); + std::thread::spawn(move || { + let salt = generate_salt(); + let key = derive_key(&password, &salt); + drop(password); + let new_security = SecurityConfig { + password_protected: true, + salt: Some(salt_to_hex(&salt)), + use_keychain: remember, + }; + let _ = tx.send((new_security, key)); + repaint.request_repaint(); + }); + self.security_prompt = Some(SecurityPrompt::Deriving { rx }); + } + } + SecurityPrompt::Deriving { rx } => { + match rx.try_recv() { + Ok((new_security, key)) => { + self.security_prompt = Some(SecurityPrompt::ConfirmRebuild { + new_security, + new_key: Some(key), + }); + } + Err(mpsc::TryRecvError::Empty) => { + egui::Window::new("Deriving key") + .collapsible(false) + .resizable(false) + .title_bar(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Deriving key…"); + }); + }); + ctx.request_repaint_after(Duration::from_millis(100)); + } + Err(mpsc::TryRecvError::Disconnected) => { + self.config_error = Some("key derivation thread died".to_string()); + self.security_prompt = None; + } + } + } + SecurityPrompt::ConfirmRebuild { + new_security, + new_key, + } => { + let title = match (new_key.is_some(), self.cfg.security.password_protected) { + (false, _) => "Disable password protection?", + (true, false) => "Enable password protection?", + (true, true) => "Change password?", + }; + let mut confirm = false; + let mut cancel = false; + egui::Window::new(title) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(420.0); + ui.label( + "Changing index encryption deletes the index and \ + re-indexes everything. Searches return incomplete \ + results until the rebuild finishes. Your files are \ + not touched.", + ); + ui.horizontal(|ui| { + if ui + .button( + egui::RichText::new("Delete & rebuild index") + .color(ui.visuals().error_fg_color), + ) + .clicked() + { + confirm = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + if cancel { + self.security_prompt = None; + } else if confirm { + let new_security = new_security.clone(); + let new_key = new_key.clone(); + self.security_prompt = None; + self.apply_security_change(new_security, new_key); + } + } + } + } + + /// Commit a confirmed security change: config, keychain, process key — + /// in that order, before the rebuild so the fresh index is created + /// under the new key (or none). + fn apply_security_change(&mut self, new_security: SecurityConfig, new_key: Option) { + let db_path = self.cfg.resolved_database_path().to_string_lossy().into_owned(); + self.cfg.security = new_security; + if let Err(e) = self.cfg.save() { + self.config_error = Some(e); + } + match (&new_key, self.cfg.security.use_keychain) { + (Some(key), true) => { + if let Err(e) = keychain::store_key(&db_path, &key.to_hex()) { + self.config_error = Some(e); + } + } + // Disabling protection, or "remember" off: no stored key may + // survive pointing at the previous encryption state. + _ => keychain::delete_key(&db_path), + } + db::set_process_key(new_key); + self.backend.coordinator.rebuild_index(); + self.counts = None; + self.dups.state = DupState::NotLoaded; + } +} + +/// Drop egui's retained text-field state (buffer + undo history) for the +/// password dialog fields. +fn purge_security_field_state(ctx: &egui::Context) { + ctx.data_mut(|d| { + d.remove::(egui::Id::new("security-pw1")); + d.remove::(egui::Id::new("security-pw2")); + }); } impl QuickSearchApp { @@ -621,6 +907,7 @@ impl eframe::App for QuickSearchApp { ui.selectable_value(&mut self.tab, Tab::Manage, "Manage Index"); ui.selectable_value(&mut self.tab, Tab::Duplicates, "Duplicates"); ui.selectable_value(&mut self.tab, Tab::Logs, "Logs"); + ui.selectable_value(&mut self.tab, Tab::Help, "Help"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("⚙").on_hover_text("Options").clicked() { if self.options.open { @@ -704,12 +991,18 @@ impl eframe::App for QuickSearchApp { } } Tab::Logs => self.logs.ui(ui), + Tab::Help => crate::help_tab::ui(ui), }); - if let Some(new_cfg) = self.options.ui(ctx, &self.cfg) { + let options_out = self.options.ui(ctx, &self.cfg); + if let Some(new_cfg) = options_out.applied { self.apply_new_config(ctx, new_cfg); } + if let Some(action) = options_out.security { + self.handle_security_action(action); + } self.rebuild_prompt_ui(ctx); + self.security_prompt_ui(ctx); self.clear_prompt_ui(ctx); self.nested_prompt_ui(ctx); self.watch_cap_prompt_ui(ctx); diff --git a/crates/quicksearch-gui/src/cli.rs b/crates/quicksearch-gui/src/cli.rs index 72c3db1..b9c0e40 100644 --- a/crates/quicksearch-gui/src/cli.rs +++ b/crates/quicksearch-gui/src/cli.rs @@ -5,13 +5,20 @@ use std::io::IsTerminal; use std::sync::atomic::AtomicU64; -use quicksearch_core::config::Config; +use quicksearch_core::config::{Config, SecurityConfig}; use quicksearch_core::db; use quicksearch_core::query::split::split_for_cascade; use quicksearch_core::search::{cascade, SearchHit, SearchOptions}; +use quicksearch_core::security::{derive_key, IndexKey}; +use zeroize::Zeroizing; use crate::format::{fmt_mtime, human_size}; +/// Scripting escape hatch for password-protected indexes. Documented with +/// its caveat: other processes of the same user can read this process's +/// environment, and exported variables end up in shell history. +const PASSWORD_ENV: &str = "QUICKSEARCH_PASSWORD"; + pub(crate) const USAGE: &str = "\ QuickSearch: indexed file search @@ -28,7 +35,12 @@ FLAGS: Query syntax matches the GUI: plain words form one phrase; filters like type:Document, modified:>=2024-01-01, path:/dir, mime:application/pdf, -name:frag combine with it."; +name:frag combine with it. + +A password-protected index unlocks from, in order: the OS keychain (when +'remember on this device' is enabled in the GUI), the QUICKSEARCH_PASSWORD +environment variable, or an interactive prompt. Note that environment +variables are visible to other processes of the same user."; /// Parse argv; `Some(exit_code)` when the invocation was CLI-mode (query /// or --help), `None` to open the GUI. @@ -89,6 +101,109 @@ pub fn maybe_run_cli() -> Option { Some(run_query(&terms.join(" "), fuzzy, limit, long)) } +/// Unlock a protected index using whichever key source is available. +/// +/// Order: keychain (when enabled) → `QUICKSEARCH_PASSWORD` → interactive +/// prompt (three attempts) → an instructive error. `try_key` installs a +/// candidate as the process key and verifies it against the index; sources +/// whose key doesn't fit fall through (keychain: stale entry) or fail hard +/// (env var, exhausted prompts). Password buffers are zeroized on drop and +/// consumed immediately by the KDF; nothing here retains or logs them. +/// +/// Pure with respect to the terminal and the database — the caller injects +/// `is_tty`, both secrets, `prompt`, and `try_key` — so the whole decision +/// table is unit-testable. +pub(crate) fn resolve_key( + security: &SecurityConfig, + is_tty: bool, + env_password: Option>, + keychain_hex: Option, + mut prompt: impl FnMut() -> Option>, + mut try_key: impl FnMut(IndexKey) -> Result<(), String>, +) -> Result<(), String> { + if !security.password_protected { + return Ok(()); + } + let salt = security.salt_bytes()?; + + if let Some(hex) = keychain_hex { + match IndexKey::from_hex(&hex).map_err(|e| format!("keychain entry: {}", e)) { + Ok(key) => match try_key(key) { + Ok(()) => return Ok(()), + Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => { + eprintln!("warning: the key remembered in the OS keychain no longer opens this index"); + } + Err(e) => return Err(e), + }, + Err(e) => eprintln!("warning: {}", e), + } + } + + if let Some(password) = env_password { + let key = derive_key(&password, &salt); + drop(password); + return match try_key(key) { + Ok(()) => Ok(()), + Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => Err(format!( + "{} does not match this index's password", + PASSWORD_ENV + )), + Err(e) => Err(e), + }; + } + + if !is_tty { + return Err(format!( + "the index is password-protected; run from a terminal, set {}, \ + or enable 'Remember on this device' in the GUI", + PASSWORD_ENV + )); + } + for _ in 0..3 { + let Some(password) = prompt() else { + return Err("failed to read password".to_string()); + }; + let key = derive_key(&password, &salt); + drop(password); + match try_key(key) { + Ok(()) => return Ok(()), + Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => { + eprintln!("Wrong password."); + } + Err(e) => return Err(e), + } + } + Err("wrong password (3 attempts)".to_string()) +} + +/// Wire [`resolve_key`] to the real terminal, environment, keychain and +/// database, installing the verified key as the process key. +fn resolve_key_for_terminal(security: &SecurityConfig, db_path: &str) -> Result<(), String> { + let keychain_hex = if security.use_keychain { + crate::keychain::load_key(db_path).unwrap_or_else(|e| { + eprintln!("warning: {}", e); + None + }) + } else { + None + }; + resolve_key( + security, + std::io::stdin().is_terminal(), + std::env::var(PASSWORD_ENV).ok().map(Zeroizing::new), + keychain_hex, + || { + rpassword::prompt_password("Index password: ") + .ok() + .map(Zeroizing::new) + }, + |key| { + db::set_process_key(Some(key)); + db::verify_process_key(db_path) + }, + ) +} + fn run_query(query: &str, fuzzy: bool, limit: Option, long: bool) -> i32 { let config = match Config::load() { Ok(c) => c, @@ -98,6 +213,19 @@ fn run_query(query: &str, fuzzy: bool, limit: Option, long: bool) -> i32 } }; let db_path = config.resolved_database_path(); + if config.security.password_protected { + if !db_path.exists() { + eprintln!( + "No usable index at {}; run the GUI once to build it.", + db_path.display() + ); + return 2; + } + if let Err(e) = resolve_key_for_terminal(&config.security, &db_path.to_string_lossy()) { + eprintln!("{}", e); + return 2; + } + } // Read-write purely so SQLite may create the WAL shared-memory file // when no other process has the index open; nothing is written. let conn = match db::open_existing(&db_path.to_string_lossy(), true) { @@ -228,3 +356,198 @@ fn render_snippet(snip: &quicksearch_core::snippet::Snippet, color: bool) -> Str } out.replace(['\n', '\r'], " ") } + +#[cfg(test)] +mod tests { + use super::*; + use quicksearch_core::security::{generate_salt, salt_to_hex}; + + fn protected() -> SecurityConfig { + SecurityConfig { + password_protected: true, + salt: Some(salt_to_hex(&generate_salt())), + use_keychain: false, + } + } + + fn mismatch() -> Result<(), String> { + Err(format!("{}wrong password", db::KEY_MISMATCH_PREFIX)) + } + + #[test] + fn unprotected_needs_nothing() { + let sec = SecurityConfig::default(); + let res = resolve_key( + &sec, + false, + None, + None, + || panic!("must not prompt"), + |_| panic!("must not try a key"), + ); + assert!(res.is_ok()); + } + + #[test] + fn protected_without_salt_is_a_hard_error() { + let sec = SecurityConfig { + password_protected: true, + salt: None, + use_keychain: false, + }; + let res = resolve_key(&sec, true, None, None, || None, |_| Ok(())); + assert!(res.unwrap_err().contains("no salt")); + } + + #[test] + fn keychain_key_wins_without_prompting() { + let sec = protected(); + let hex = "ab".repeat(32); + let mut tried = 0; + let res = resolve_key( + &sec, + true, + Some(Zeroizing::new("unused".to_string())), + Some(hex.clone()), + || panic!("must not prompt"), + |key| { + tried += 1; + assert_eq!(key.to_hex(), hex); + Ok(()) + }, + ); + assert!(res.is_ok()); + assert_eq!(tried, 1); + } + + #[test] + fn stale_keychain_falls_through_to_env() { + let sec = protected(); + let mut calls = 0; + let res = resolve_key( + &sec, + false, + Some(Zeroizing::new("pw".to_string())), + Some("cd".repeat(32)), + || panic!("must not prompt"), + |_| { + calls += 1; + if calls == 1 { + mismatch() // the stale keychain key + } else { + Ok(()) // the env-derived key + } + }, + ); + assert!(res.is_ok()); + assert_eq!(calls, 2); + } + + #[test] + fn malformed_keychain_entry_is_skipped() { + let sec = protected(); + let res = resolve_key( + &sec, + false, + Some(Zeroizing::new("pw".to_string())), + Some("not hex at all".to_string()), + || panic!("must not prompt"), + |_| Ok(()), + ); + assert!(res.is_ok(), "bad keychain data must not be fatal"); + } + + #[test] + fn wrong_env_password_fails_without_prompting() { + let sec = protected(); + let res = resolve_key( + &sec, + true, // even on a TTY: a wrong explicit secret is an error, not a prompt + Some(Zeroizing::new("wrong".to_string())), + None, + || panic!("must not prompt"), + |_| mismatch(), + ); + assert!(res.unwrap_err().contains(PASSWORD_ENV)); + } + + #[test] + fn no_tty_no_sources_is_instructive() { + let sec = protected(); + let err = resolve_key(&sec, false, None, None, || None, |_| Ok(())) + .unwrap_err(); + assert!(err.contains(PASSWORD_ENV)); + assert!(err.contains("Remember on this device")); + } + + #[test] + fn prompt_retries_then_succeeds() { + let sec = protected(); + let mut prompts = 0; + let mut tries = 0; + let res = resolve_key( + &sec, + true, + None, + None, + || { + prompts += 1; + Some(Zeroizing::new(format!("attempt{}", prompts))) + }, + |_| { + tries += 1; + if tries < 2 { + mismatch() + } else { + Ok(()) + } + }, + ); + assert!(res.is_ok()); + assert_eq!(prompts, 2); + } + + #[test] + fn prompt_gives_up_after_three_wrong_passwords() { + let sec = protected(); + let mut prompts = 0; + let err = resolve_key( + &sec, + true, + None, + None, + || { + prompts += 1; + Some(Zeroizing::new("nope".to_string())) + }, + |_| mismatch(), + ) + .unwrap_err(); + assert_eq!(prompts, 3); + assert!(err.contains("3 attempts")); + } + + #[test] + fn unreadable_prompt_is_an_error() { + let sec = protected(); + let err = resolve_key(&sec, true, None, None, || None, |_| Ok(())).unwrap_err(); + assert!(err.contains("failed to read password")); + } + + #[test] + fn non_mismatch_errors_are_fatal_immediately() { + // e.g. the database file vanished between existence check and open: + // retrying the password would mislead the user. + let sec = protected(); + let err = resolve_key( + &sec, + true, + None, + None, + || Some(Zeroizing::new("pw".to_string())), + |_| Err("Failed to open database at /x: unable to open database file".to_string()), + ) + .unwrap_err(); + assert!(err.contains("unable to open")); + } +} diff --git a/crates/quicksearch-gui/src/cli_main.rs b/crates/quicksearch-gui/src/cli_main.rs index 778d833..0edd284 100644 --- a/crates/quicksearch-gui/src/cli_main.rs +++ b/crates/quicksearch-gui/src/cli_main.rs @@ -9,6 +9,9 @@ mod cli; // The GUI uses more of this module than the CLI does. #[allow(dead_code)] mod format; +// The GUI stores/deletes keychain entries; the CLI only reads them. +#[allow(dead_code)] +mod keychain; fn main() { // `maybe_run_cli` returns `None` for "no query given", which the combined diff --git a/crates/quicksearch-gui/src/duplicates_tab.rs b/crates/quicksearch-gui/src/duplicates_tab.rs index bbefa34..c5167d9 100644 --- a/crates/quicksearch-gui/src/duplicates_tab.rs +++ b/crates/quicksearch-gui/src/duplicates_tab.rs @@ -65,7 +65,7 @@ impl DuplicatesTab { egui::RichText::new("Showing the 500 largest groups.").small().weak(), ); } - egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| { + let scroll = egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| { for (i, group) in groups.iter().enumerate() { let name = group .members @@ -104,6 +104,7 @@ impl DuplicatesTab { }); } }); + crate::ui_util::more_below_hint(ui, &scroll); } } actions diff --git a/crates/quicksearch-gui/src/help_tab.rs b/crates/quicksearch-gui/src/help_tab.rs new file mode 100644 index 0000000..658640c --- /dev/null +++ b/crates/quicksearch-gui/src/help_tab.rs @@ -0,0 +1,122 @@ +//! The Help tab: a quickstart guide for first-time users. +//! +//! Everything technical — building, configuration, the complete query +//! reference — deliberately stays in README.md; this page only has to get +//! someone from a fresh install to useful search results. + +pub fn ui(ui: &mut egui::Ui) { + let scroll = egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + // Cap the column like a document page: a maximized window + // would otherwise stretch every paragraph into one long line. + ui.set_max_width(620.0); + + ui.heading("Welcome to QuickSearch"); + ui.add_space(4.0); + ui.label( + "QuickSearch keeps an index of the folders you choose and finds \ + files by name and by what is inside them, as you type.", + ); + + ui.add_space(12.0); + ui.heading("Getting started"); + ui.add_space(4.0); + ui.label( + "1. The first time QuickSearch runs it starts indexing your home \ + folder on its own. The status bar along the bottom shows the \ + progress, and searching already works while it runs.", + ); + ui.label( + "2. To index different folders, open the Manage Index tab and edit \ + the folder list. Indexed folders are watched, so the index follows \ + your files as they change.", + ); + ui.label( + "3. Type in the search box on the Search tab. Results appear as \ + you type, best matches first.", + ); + + ui.add_space(12.0); + ui.heading("Searching"); + ui.add_space(4.0); + ui.label("Plain words match file names, file contents, and paths:"); + ui.monospace("quarterly budget"); + ui.label( + "Filters narrow the results and combine freely with the search \ + words:", + ); + ui.monospace("type:Document modified:>=2024-01-01 report"); + ui.label( + "The ? button next to the search box shows the full query syntax.", + ); + ui.add_space(6.0); + ui.label( + "• Tick Fuzzy to also find matches with typos in them, at some \ + cost in speed.", + ); + ui.label( + "• Click a column header — Name, Path, Size, Modified, Rank — to \ + sort the results; click it again to reverse the order.", + ); + ui.label( + "• Right-click a result to open it, open its containing folder, \ + or hide files like it from the results.", + ); + ui.label( + "• Matches inside a file's contents show a snippet of the \ + surrounding text under the file name.", + ); + + ui.add_space(12.0); + ui.heading("The other tabs"); + ui.add_space(4.0); + egui::Grid::new("help-tabs") + .num_columns(2) + .spacing([18.0, 5.0]) + .show(ui, |ui| { + let row = |ui: &mut egui::Ui, name: &str, what: &str| { + ui.strong(name); + ui.label(what); + ui.end_row(); + }; + row( + ui, + "Manage Index", + "indexing status and controls, the indexed folder list, \ + and the filters that decide what is skipped", + ); + row(ui, "Duplicates", "files whose contents are identical, grouped"); + row( + ui, + "Logs", + "warnings from indexing and folder watching that a \ + terminal would have shown", + ); + row(ui, "⚙ (top right)", "application options"); + }); + + ui.add_space(12.0); + ui.heading("Terminal"); + ui.add_space(4.0); + ui.label("QuickSearch also searches straight from a terminal:"); + ui.monospace("quicksearch \"quarterly budget\""); + ui.label( + "On Windows use quicksearch-cli. Either way, --help lists all \ + the flags.", + ); + + ui.add_space(12.0); + ui.label( + egui::RichText::new( + "Everything else — building from source, configuration, the \ + complete query reference — is covered in README.md in the \ + QuickSearch folder (installed under /usr/share/doc/quicksearch/ \ + on Debian and Ubuntu).", + ) + .small() + .weak(), + ); + }); + crate::ui_util::more_below_hint(ui, &scroll); +} diff --git a/crates/quicksearch-gui/src/keychain.rs b/crates/quicksearch-gui/src/keychain.rs new file mode 100644 index 0000000..374a69b --- /dev/null +++ b/crates/quicksearch-gui/src/keychain.rs @@ -0,0 +1,42 @@ +//! OS keychain storage for the derived index key. +//! +//! Stores the *derived* SQLCipher key (hex), never the password: a +//! keychain unlock skips the ~0.5 s Argon2 derivation, and the password +//! itself never persists anywhere. Entries are keyed by database path so +//! portable installs and multiple profiles don't clobber each other. +//! +//! Every failure here is non-fatal by design — a missing Secret Service +//! daemon, a locked keyring, a denied prompt — and callers fall back to +//! asking for the password. + +use keyring::Entry; + +const SERVICE: &str = "quicksearch"; + +fn entry(db_path: &str) -> Result { + Entry::new(SERVICE, db_path).map_err(|e| format!("keychain unavailable: {}", e)) +} + +/// Remember the derived key for this database on this machine. +pub fn store_key(db_path: &str, key_hex: &str) -> Result<(), String> { + entry(db_path)? + .set_password(key_hex) + .map_err(|e| format!("keychain store failed: {}", e)) +} + +/// The remembered key, `Ok(None)` when nothing is stored. +pub fn load_key(db_path: &str) -> Result, String> { + match entry(db_path)?.get_password() { + Ok(hex) => Ok(Some(hex)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("keychain read failed: {}", e)), + } +} + +/// Forget the remembered key. Best-effort: an entry that never existed or +/// a dead keychain daemon are both fine outcomes for "forget". +pub fn delete_key(db_path: &str) { + if let Ok(entry) = entry(db_path) { + let _ = entry.delete_credential(); + } +} diff --git a/crates/quicksearch-gui/src/logs_tab.rs b/crates/quicksearch-gui/src/logs_tab.rs index 04fdc30..774a154 100644 --- a/crates/quicksearch-gui/src/logs_tab.rs +++ b/crates/quicksearch-gui/src/logs_tab.rs @@ -148,7 +148,7 @@ impl LogsTab { // only while every row is exactly one line tall. ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); let row_height = ui.text_style_height(&egui::TextStyle::Monospace); - egui::ScrollArea::both() + let scroll = egui::ScrollArea::both() .auto_shrink([false; 2]) .stick_to_bottom(self.follow) .show_rows(ui, row_height, shown.len(), |ui, range| { @@ -172,6 +172,7 @@ impl LogsTab { }); } }); + crate::ui_util::more_below_hint(ui, &scroll); } } diff --git a/crates/quicksearch-gui/src/main.rs b/crates/quicksearch-gui/src/main.rs index 173f89c..0601230 100644 --- a/crates/quicksearch-gui/src/main.rs +++ b/crates/quicksearch-gui/src/main.rs @@ -14,6 +14,8 @@ mod backend; mod cli; mod duplicates_tab; mod format; +mod help_tab; +mod keychain; mod logs_tab; mod manage_tab; mod options; @@ -21,6 +23,8 @@ mod platform; mod query_highlight; mod search_tab; mod tracker; +mod unlock; +mod ui_util; use quicksearch_core::config::Config; @@ -69,6 +73,13 @@ fn main() { }; let initial_query = seed_query(); + // With protection on, try the keychain before the window opens; a + // verified key means no prompt at all. Anything else starts locked — + // the unlock screen owns password entry, bad-salt reporting, and the + // forgot-password escape hatch. No index is touched until unlocked. + let start_unlocked = + !config.security.password_protected || unlock::try_keychain_unlock(&config); + let native_options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("QuickSearch") @@ -83,9 +94,13 @@ fn main() { "QuickSearch", native_options, Box::new(move |cc| { - app::QuickSearchApp::new(cc, config, config_error, initial_query) - .map(|app| Box::new(app) as Box) - .map_err(|e| e.into()) + let gate = if start_unlocked { + unlock::Gate::running(&cc.egui_ctx, config, config_error, initial_query) + .map_err(Box::::from)? + } else { + unlock::Gate::locked(config, config_error, initial_query) + }; + Ok(Box::new(gate) as Box) }), ); if let Err(e) = result { diff --git a/crates/quicksearch-gui/src/manage_tab.rs b/crates/quicksearch-gui/src/manage_tab.rs index 015c682..afcaf9f 100644 --- a/crates/quicksearch-gui/src/manage_tab.rs +++ b/crates/quicksearch-gui/src/manage_tab.rs @@ -23,15 +23,18 @@ pub struct ManageActions { pub struct ManageTab { pub speed: SpeedTracker, - /// Multiline editors, one entry per line; synced from config on tab - /// entry and parsed back on Apply. + /// Multiline editor, one extension per line; synced from config and + /// parsed back on Apply. ext_filter_text: String, - ignore_filter_text: String, new_root: String, + /// Text of the inline "add ignore pattern" box. + new_ignore: String, /// Inline error from a rejected root add (nested/duplicate). root_error: Option, - editors_synced: bool, - /// Draft of the indexing/processing knobs edited in-place. + /// The config the draft was last synced from. `None` forces a full + /// resync (first frame, and right after our own Apply). + baseline: Option, + /// Draft of the roots/filters/indexing knobs edited in-place. draft: Option, } @@ -40,10 +43,10 @@ impl ManageTab { ManageTab { speed: SpeedTracker::new(), ext_filter_text: String::new(), - ignore_filter_text: String::new(), new_root: String::new(), + new_ignore: String::new(), root_error: None, - editors_synced: false, + baseline: None, draft: None, } } @@ -62,18 +65,48 @@ impl ManageTab { } } + /// Reconcile the draft with the live config, every frame. This is what + /// keeps the ignore-pattern list realtime: a filter persisted from the + /// Search tab shows up here on the next frame, staged edits or not. fn sync_editors(&mut self, config: &Config) { - if !self.editors_synced { - self.ext_filter_text = config.indexing.content_extensions.join("\n"); - self.ignore_filter_text = config.indexing.ignore_patterns.join("\n"); - self.draft = Some(config.clone()); - self.editors_synced = true; + let Some(baseline) = &self.baseline else { + // First frame, or right after our own Apply. + return self.resync(config); + }; + if baseline == config { + return; } + // The config changed elsewhere (Options apply, a filter persisted + // from the Search tab, the fuzzy toggle's direct save…). + let dirty = self.draft.as_ref() != Some(baseline) + || self.ext_filter_text != baseline.indexing.content_extensions.join("\n"); + if !dirty { + // Nothing staged, nothing to lose. + return self.resync(config); + } + // Staged edits exist: keep the sections this tab edits, adopt + // everything else so a later Apply cannot revert changes made + // elsewhere, and fold in ignore patterns added externally. + let draft = self.draft.take().expect("synced"); + let mut merged = config.clone(); + merged.paths.indexing_paths = draft.paths.indexing_paths; + merged.indexing = draft.indexing; + merged.processing = draft.processing; + for pat in &config.indexing.ignore_patterns { + if !baseline.indexing.ignore_patterns.contains(pat) + && !merged.indexing.ignore_patterns.contains(pat) + { + merged.indexing.ignore_patterns.push(pat.clone()); + } + } + self.draft = Some(merged); + self.baseline = Some(config.clone()); } - /// Force a re-sync next frame (config changed elsewhere). - pub fn invalidate_editors(&mut self) { - self.editors_synced = false; + fn resync(&mut self, config: &Config) { + self.ext_filter_text = config.indexing.content_extensions.join("\n"); + self.draft = Some(config.clone()); + self.baseline = Some(config.clone()); } pub fn ui( @@ -85,7 +118,7 @@ impl ManageTab { let mut actions = ManageActions::default(); self.sync_editors(config); - egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| { + let scroll = egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| { // --- Status --------------------------------------------------- ui.heading("Status"); status_panel(ui, state, &self.speed); @@ -240,11 +273,57 @@ impl ManageTab { .hint_text("txt\nmd\npdf"), ); cols[1].label("Ignore patterns (excluded entirely):"); - cols[1].add( - egui::TextEdit::multiline(&mut self.ignore_filter_text) - .desired_rows(4) - .desired_width(f32::INFINITY) - .hint_text(".git\nnode_modules\n*.tmp"), + let mut remove_pat: Option = None; + for (i, pat) in draft.indexing.ignore_patterns.iter().enumerate() { + cols[1].horizontal(|ui| { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.small_button("Remove").clicked() { + remove_pat = Some(i); + } + ui.with_layout( + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.monospace(pat); + }, + ); + }, + ); + }); + } + if draft.indexing.ignore_patterns.is_empty() { + cols[1].label(egui::RichText::new("No ignore patterns.").small().weak()); + } + if let Some(i) = remove_pat { + draft.indexing.ignore_patterns.remove(i); + } + cols[1].horizontal(|ui| { + let (response, valid) = crate::ui_util::pattern_edit( + ui, + &mut self.new_ignore, + 180.0, + "*.tmp or node_modules", + ); + let submitted = + response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if ui.add_enabled(valid, egui::Button::new("Add")).clicked() + || (submitted && valid) + { + let pat = self.new_ignore.trim().to_string(); + if !draft.indexing.ignore_patterns.contains(&pat) { + draft.indexing.ignore_patterns.push(pat); + } + self.new_ignore.clear(); + } + }); + cols[1].label( + egui::RichText::new( + "Changes apply on Apply & Save (may trigger a rebuild). \ + Session-only filters are shown and removed on the Search tab.", + ) + .small() + .weak(), ); }); ui.separator(); @@ -256,19 +335,25 @@ impl ManageTab { config_editor_ui(ui, draft, Section::Processing); ui.add_space(8.0); - if ui.button("Apply & Save").clicked() { + if ui + .add(crate::ui_util::bordered_button( + "Apply & Save", + crate::ui_util::BLUE, + )) + .clicked() + { let mut new_config = draft.clone(); new_config.indexing.content_extensions = parse_lines(&self.ext_filter_text); - new_config.indexing.ignore_patterns = parse_lines(&self.ignore_filter_text); let roots = new_config.paths.indexing_paths.clone(); new_config .indexing .root_workers .retain(|root, _| roots.contains(root)); actions.apply_config = Some(new_config); - self.editors_synced = false; + self.baseline = None; } }); + crate::ui_util::more_below_hint(ui, &scroll); actions } @@ -445,3 +530,98 @@ fn root_row(ui: &mut egui::Ui, r: &RootProgress) { ui.label(egui::RichText::new(middle_truncate(f, 90)).small().weak()); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn synced_tab(config: &Config) -> ManageTab { + let mut tab = ManageTab::new(); + tab.sync_editors(config); + tab + } + + #[test] + fn identical_config_leaves_draft_untouched() { + let cfg = Config::default(); + let mut tab = synced_tab(&cfg); + // Stage an edit, then sync against the unchanged config. + tab.draft.as_mut().unwrap().indexing.ignore_patterns.push("*.log".into()); + tab.sync_editors(&cfg); + assert!(tab + .draft + .as_ref() + .unwrap() + .indexing + .ignore_patterns + .contains(&"*.log".to_string())); + } + + #[test] + fn clean_draft_adopts_external_changes_wholesale() { + let cfg = Config::default(); + let mut tab = synced_tab(&cfg); + let mut external = cfg.clone(); + external.indexing.ignore_patterns.push("*.log".into()); + external.search.fuzzy_default = !external.search.fuzzy_default; + tab.sync_editors(&external); + assert_eq!(tab.draft.as_ref().unwrap(), &external); + assert_eq!(tab.baseline.as_ref().unwrap(), &external); + } + + #[test] + fn staged_edits_survive_an_external_persist() { + let cfg = Config::default(); + let mut tab = synced_tab(&cfg); + // Stage a removal of the first default pattern. + let removed = tab + .draft + .as_mut() + .unwrap() + .indexing + .ignore_patterns + .remove(0); + // Meanwhile the Search tab persists a new filter. + let mut external = cfg.clone(); + external.indexing.ignore_patterns.push("*.log".into()); + tab.sync_editors(&external); + let draft = tab.draft.as_ref().unwrap(); + assert!(!draft.indexing.ignore_patterns.contains(&removed)); + assert!(draft.indexing.ignore_patterns.contains(&"*.log".to_string())); + assert_eq!(tab.baseline.as_ref().unwrap(), &external); + } + + #[test] + fn dirty_draft_adopts_sections_owned_elsewhere() { + let cfg = Config::default(); + let mut tab = synced_tab(&cfg); + tab.draft.as_mut().unwrap().indexing.ignore_patterns.push("*.bak".into()); + // The fuzzy toggle saves the config directly, outside this tab. + let mut external = cfg.clone(); + external.search.fuzzy_default = !cfg.search.fuzzy_default; + tab.sync_editors(&external); + let draft = tab.draft.as_ref().unwrap(); + assert_eq!(draft.search.fuzzy_default, external.search.fuzzy_default); + assert!(draft.indexing.ignore_patterns.contains(&"*.bak".to_string())); + } + + #[test] + fn external_pattern_is_not_duplicated_into_a_draft_that_has_it() { + let cfg = Config::default(); + let mut tab = synced_tab(&cfg); + tab.draft.as_mut().unwrap().indexing.ignore_patterns.push("*.log".into()); + let mut external = cfg.clone(); + external.indexing.ignore_patterns.push("*.log".into()); + tab.sync_editors(&external); + let count = tab + .draft + .as_ref() + .unwrap() + .indexing + .ignore_patterns + .iter() + .filter(|p| p.as_str() == "*.log") + .count(); + assert_eq!(count, 1); + } +} diff --git a/crates/quicksearch-gui/src/options.rs b/crates/quicksearch-gui/src/options.rs index db4b5c2..a353005 100644 --- a/crates/quicksearch-gui/src/options.rs +++ b/crates/quicksearch-gui/src/options.rs @@ -11,6 +11,27 @@ pub enum Section { Search, } +/// A click in the Security block. Unlike the rest of the Options window +/// these are not draft edits: passwords are not config fields, and every +/// action here runs its own explicit flow (with a rebuild warning where +/// one is required) in the app. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurityAction { + Enable, + Disable, + ChangePassword, + SetKeychain(bool), +} + +/// What one frame of the Options window produced. +#[derive(Default)] +pub struct OptionsOutput { + /// "Apply & Save" was clicked with this draft. + pub applied: Option, + /// A Security block action was clicked. + pub security: Option, +} + pub struct OptionsWindow { pub open: bool, draft: Option, @@ -29,16 +50,16 @@ impl OptionsWindow { self.draft = Some(current.clone()); } - /// Render; returns a new config when the user applied changes. - pub fn ui(&mut self, ctx: &egui::Context, current: &Config) -> Option { + /// Render; reports an applied draft config and/or a security action. + pub fn ui(&mut self, ctx: &egui::Context, current: &Config) -> OptionsOutput { if !self.open { self.draft = None; - return None; + return OptionsOutput::default(); } if self.draft.is_none() { self.draft = Some(current.clone()); } - let mut applied = None; + let mut out = OptionsOutput::default(); let mut open = self.open; let draft = self.draft.as_mut().unwrap(); @@ -93,12 +114,20 @@ impl OptionsWindow { ); ui.end_row(); }); + ui.separator(); + + // Security acts on the live config, not the draft: each + // action opens its own confirmation flow immediately. + // The KDF salt is deliberately never shown here (or + // anywhere else in the GUI). + ui.heading("Security"); + out.security = security_ui(ui, current); }); ui.separator(); ui.horizontal(|ui| { if ui.button("Apply & Save").clicked() { - applied = Some(draft.clone()); + out.applied = Some(draft.clone()); } ui.label( egui::RichText::new( @@ -115,10 +144,51 @@ impl OptionsWindow { if !self.open { self.draft = None; } - applied + out } } +/// The Security block: status plus action buttons. Never renders the salt. +fn security_ui(ui: &mut egui::Ui, current: &Config) -> Option { + let mut action = None; + if current.security.password_protected { + ui.label("The index is encrypted; a password is asked for at startup."); + ui.horizontal(|ui| { + if ui.button("Change password…").clicked() { + action = Some(SecurityAction::ChangePassword); + } + if ui.button("Disable protection…").clicked() { + action = Some(SecurityAction::Disable); + } + }); + let mut remember = current.security.use_keychain; + if ui + .checkbox(&mut remember, "Remember on this device") + .on_hover_text( + "Stores the derived key (not the password) in the OS keychain \ + and skips the startup prompt on this machine.", + ) + .changed() + { + action = Some(SecurityAction::SetKeychain(remember)); + } + } else { + ui.label("The index is not encrypted."); + if ui.button("Enable password protection…").clicked() { + action = Some(SecurityAction::Enable); + } + ui.label( + egui::RichText::new( + "The index stores the names and text of your files. A password \ + encrypts it on disk; enabling one rebuilds the index.", + ) + .small() + .weak(), + ); + } + action +} + /// One implementation of the per-section config controls, shared by the /// Options window and the Manage tab. pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section) { @@ -164,6 +234,13 @@ pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section }); ui.end_row(); + ui.label(""); + ui.hyperlink_to( + "Tokenizer documentation", + "https://www.sqlite.org/fts5.html#tokenizers", + ); + ui.end_row(); + ui.label("Hash sample size (bytes)"); ui.add(egui::DragValue::new(&mut config.processing.hash_length).range(512..=1_048_576)); ui.end_row(); @@ -197,7 +274,7 @@ pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section } Section::Search => { egui::Grid::new("cfg-search").num_columns(2).show(ui, |ui| { - ui.label("Fuzzy stages on by default"); + ui.label("Fuzzy search ON by default"); ui.checkbox(&mut config.search.fuzzy_default, ""); ui.end_row(); @@ -214,7 +291,7 @@ pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section ui.label( egui::RichText::new(warning) .small() - .color(egui::Color32::from_rgb(220, 150, 40)), + .color(crate::ui_util::ORANGE), ); } }); diff --git a/crates/quicksearch-gui/src/search_tab.rs b/crates/quicksearch-gui/src/search_tab.rs index 591a4f4..6b27528 100644 --- a/crates/quicksearch-gui/src/search_tab.rs +++ b/crates/quicksearch-gui/src/search_tab.rs @@ -21,9 +21,11 @@ pub enum SortKey { } pub struct IgnoreDialog { - pub source_name: String, pub source_path: String, - pub pattern: String, + /// `*.{ext}`, when the file has an extension. + pub ext_pattern: Option, + pub name_pattern: String, + pub dir_pattern: String, pub persist: bool, } @@ -69,6 +71,11 @@ pub struct SearchTab { pub ignore_dialog: Option, pub help_open: bool, has_snippets: bool, + /// Display-row index hovered last frame; drives the row hover fill. + /// egui_extras' own tracking needs `row.response().hovered()`, which is + /// false whenever a selectable label wins the hit-test, so we track it + /// ourselves via `contains_pointer()`. + hovered_row: Option, focus_query: bool, /// Query syntax-highlight segments, cached per text. highlight: crate::query_highlight::HighlightCache, @@ -98,6 +105,7 @@ impl SearchTab { ignore_dialog: None, help_open: false, has_snippets: false, + hovered_row: None, focus_query: true, highlight: Default::default(), } @@ -416,8 +424,9 @@ impl SearchTab { let text_height = egui::TextStyle::Body.resolve(ui.style()).size + 4.0; let mut open_ignore_dialog: Option = None; + let mut hovered_now: Option = None; - ui.push_id("results", |ui| { + let table_scroll = ui.push_id("results", |ui| { let mut table = TableBuilder::new(ui) .striped(true) .resizable(true) @@ -457,15 +466,23 @@ impl SearchTab { .body(|body| { let order = self.order.clone(); body.rows(text_height, order.len(), |mut row| { - let result_ix = order[row.index()] as usize; + let display_ix = row.index(); + let result_ix = order[display_ix] as usize; let hit = &self.results[result_ix]; row.set_selected(self.selected == Some(result_ix as u32)); + row.set_hovered(self.hovered_row == Some(display_ix)); + + // Labels stay selectable for copy-paste, which makes + // them win egui's hit-test over the row. Collect their + // responses and union them into the row's below so + // clicks land even when the pointer is over glyphs. + let mut cell_responses: Vec = Vec::new(); row.col(|ui| { - ui.label(&hit.name); + cell_responses.push(ui.label(&hit.name)); }); row.col(|ui| { - ui.label(egui::RichText::new(&hit.path).weak()); + cell_responses.push(ui.label(egui::RichText::new(&hit.path).weak())); }); if self.has_snippets { let snippet = hit.snippet.clone(); @@ -477,7 +494,7 @@ impl SearchTab { if let Some(snip) = &snippet { let width = ui.available_width(); let job = centered_match_job(ui, snip, width, whole_field); - let response = ui + let mut response = ui .with_layout( egui::Layout::centered_and_justified( egui::Direction::LeftToRight, @@ -487,40 +504,41 @@ impl SearchTab { .inner; if !snip.ranges.is_empty() { let hover = snip.clone(); - response.on_hover_ui(|ui| { + response = response.on_hover_ui(|ui| { ui.set_max_width(520.0); let job = snippet_job(ui, &hover, 10); ui.label(job); }); } + cell_responses.push(response); } }); } row.col(|ui| { - ui.with_layout( + let response = ui.with_layout( egui::Layout::centered_and_justified( egui::Direction::LeftToRight, ), - |ui| { - ui.label(human_size(hit.size)); - }, + |ui| ui.label(human_size(hit.size)), ); + cell_responses.push(response.inner); }); row.col(|ui| { let color = recency_color(ui, hit.mtime); - ui.with_layout( + let response = ui.with_layout( egui::Layout::centered_and_justified( egui::Direction::LeftToRight, ), |ui| { ui.label( egui::RichText::new(fmt_mtime(hit.mtime)).color(color), - ); + ) }, ); + cell_responses.push(response.inner); }); row.col(|ui| { - ui.with_layout( + let response = ui.with_layout( egui::Layout::centered_and_justified( egui::Direction::LeftToRight, ), @@ -529,13 +547,20 @@ impl SearchTab { egui::RichText::new(format!(" {:.2} ", hit.rank)) .background_color(rank_tier_color(hit.stage)) .color(egui::Color32::from_rgb(32, 32, 32)), - ); + ) }, ); + cell_responses.push(response.inner); }); - let response = row.response(); - if response.clicked() { + let mut response = row.response(); + for r in cell_responses { + response = response | r; + } + if response.contains_pointer() { + hovered_now = Some(display_ix); + } + if response.clicked() || response.secondary_clicked() { self.selected = Some(result_ix as u32); } if response.double_clicked() { @@ -543,12 +568,16 @@ impl SearchTab { } response.context_menu(|ui| { let path = self.results[result_ix].path.clone(); + if ui.button("Open containing folder").clicked() { + platform::reveal_in_folder(&path); + ui.close(); + } if ui.button("Open").clicked() { platform::open_file(&path); ui.close(); } - if ui.button("Open containing folder").clicked() { - platform::reveal_in_folder(&path); + if ui.button("Copy path").clicked() { + ui.ctx().copy_text(path.clone()); ui.close(); } ui.separator(); @@ -558,15 +587,26 @@ impl SearchTab { } }); }); - }); - }); + }) + }) + .inner; + crate::ui_util::more_below_hint(ui, &table_scroll); + self.hovered_row = hovered_now; if let Some(ix) = open_ignore_dialog { let hit = &self.results[ix]; self.ignore_dialog = Some(IgnoreDialog { - source_name: hit.name.clone(), source_path: hit.path.clone(), - pattern: hit.name.clone(), + ext_pattern: std::path::Path::new(&hit.name) + .extension() + .and_then(|e| e.to_str()) + .map(|e| format!("*.{}", e)), + name_pattern: hit.name.clone(), + dir_pattern: std::path::Path::new(&hit.path) + .parent() + .and_then(|p| p.to_str()) + .map(|p| format!("{}/*", p)) + .unwrap_or_default(), persist: false, }); } @@ -584,45 +624,85 @@ impl SearchTab { } fn ignore_dialog_ui(&mut self, ctx: &egui::Context, actions: &mut SearchActions) { + use crate::ui_util::{bordered_button, pattern_edit, BLUE, ORANGE}; let Some(dialog) = &mut self.ignore_dialog else { return; }; - let mut apply = false; + // The chosen pattern; each "Ignore this …" button applies exactly + // that filter and closes the dialog. + let mut chosen: Option = None; let mut cancel = false; egui::Window::new("Ignore filter") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .show(ctx, |ui| { + ui.set_min_width(430.0); ui.label(format!("From: {}", dialog.source_path)); - ui.add_space(4.0); + ui.separator(); + + // --- Extension --------------------------------------------- ui.horizontal(|ui| { - if ui.button("This name").clicked() { - dialog.pattern = dialog.source_name.clone(); - } - if let Some(ext) = std::path::Path::new(&dialog.source_name) - .extension() - .and_then(|e| e.to_str()) - { - if ui.button(format!("*.{}", ext)).clicked() { - dialog.pattern = format!("*.{}", ext); + match &dialog.ext_pattern { + Some(ext) => { + ui.monospace(ext); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .add(bordered_button("Ignore this extension", ORANGE)) + .clicked() + { + chosen = Some(ext.clone()); + } + }, + ); } - } - if let Some(parent) = std::path::Path::new(&dialog.source_path) - .parent() - .and_then(|p| p.to_str()) - { - if ui.button("This directory").clicked() { - dialog.pattern = format!("{}/*", parent); + None => { + ui.label(egui::RichText::new("(no file extension)").weak()); } } }); - ui.add( - egui::TextEdit::singleline(&mut dialog.pattern) - .desired_width(360.0) - .hint_text("glob pattern"), - ); - ui.checkbox(&mut dialog.persist, "Persist to config"); + ui.separator(); + + // --- Filename ---------------------------------------------- + ui.horizontal(|ui| { + let (_, valid) = + pattern_edit(ui, &mut dialog.name_pattern, 240.0, "filename or glob"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_enabled(valid, bordered_button("Ignore this filename", ORANGE)) + .clicked() + { + chosen = Some(dialog.name_pattern.trim().to_string()); + } + }); + }); + ui.separator(); + + // --- Directory --------------------------------------------- + ui.horizontal(|ui| { + let (_, valid) = + pattern_edit(ui, &mut dialog.dir_pattern, 240.0, "directory glob"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_enabled(valid, bordered_button("Ignore this directory", ORANGE)) + .clicked() + { + chosen = Some(dialog.dir_pattern.trim().to_string()); + } + }); + }); + ui.separator(); + + // --- Persist + close --------------------------------------- + egui::Frame::new() + .stroke(egui::Stroke::new(1.0, BLUE)) + .corner_radius(4) + .inner_margin(egui::Margin::symmetric(6, 3)) + .show(ui, |ui| { + ui.checkbox(&mut dialog.persist, "Persist to config"); + }); ui.label( egui::RichText::new( "Session filters hide results immediately. Persisted filters also \ @@ -631,27 +711,19 @@ impl SearchTab { .small() .weak(), ); - ui.horizontal(|ui| { - if ui.button("Apply").clicked() { - apply = true; - } - if ui.button("Cancel").clicked() { - cancel = true; - } - }); + if ui.button("Cancel").clicked() { + cancel = true; + } }); - if apply { + if let Some(pattern) = chosen { let dialog = self.ignore_dialog.take().unwrap(); - let pattern = dialog.pattern.trim().to_string(); - if !pattern.is_empty() { - if !self.session_ignores.contains(&pattern) { - self.session_ignores.push(pattern.clone()); - } - if dialog.persist { - actions.persist_ignore = Some(pattern); - } - actions.rerun = true; + if !self.session_ignores.contains(&pattern) { + self.session_ignores.push(pattern.clone()); } + if dialog.persist { + actions.persist_ignore = Some(pattern); + } + actions.rerun = true; } else if cancel { self.ignore_dialog = None; } @@ -945,3 +1017,96 @@ fn recency_color(ui: &egui::Ui, mtime: i64) -> egui::Color32 { lerp(fresh.b(), old.b()), ) } + +#[cfg(test)] +mod tests { + use super::*; + + fn tab_with_results(n: usize) -> SearchTab { + let mut tab = SearchTab::new(false); + tab.query = "alpha".into(); + tab.focus_query = false; + tab.results = (0..n) + .map(|i| SearchHit { + file_id: i as i64, + name: format!("alpha_widget_{i}.txt"), + path: format!("/qs-test/alpha_widget_{i}.txt"), + size: 116, + mtime: 1_700_000_000, + rank: 3.0, + stage: 1, + snippet: None, + }) + .collect(); + tab.order = (0..n as u32).collect(); + tab + } + + fn run_frame( + ctx: &egui::Context, + tab: &mut SearchTab, + events: Vec, + ) -> egui::FullOutput { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1000.0, 700.0), + )), + events, + ..Default::default() + }; + ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + tab.ui(ui); + }); + }) + } + + /// Walk the pointer down the name column until it sits on `row`'s label + /// glyphs. The text cursor proves a selectable label won the hit-test — + /// exactly the case where row hover, clicks, and the context menu used + /// to go dead — while `hovered_row` proves the row still tracks hover. + fn hover_row_text(ctx: &egui::Context, tab: &mut SearchTab, row: usize) -> egui::Pos2 { + for y in 40..250 { + let pos = egui::pos2(60.0, y as f32); + let out = run_frame(ctx, tab, vec![egui::Event::PointerMoved(pos)]); + let over_text = out.platform_output.cursor_icon == egui::CursorIcon::Text; + if over_text && tab.hovered_row == Some(row) { + return pos; + } + } + panic!("never landed on row {row}'s label text"); + } + + fn click(pos: egui::Pos2, button: egui::PointerButton) -> Vec { + [true, false] + .into_iter() + .map(|pressed| egui::Event::PointerButton { + pos, + button, + pressed, + modifiers: egui::Modifiers::default(), + }) + .collect() + } + + #[test] + fn rows_respond_over_selectable_label_text() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(3); + run_frame(&ctx, &mut tab, Vec::new()); + + // Hovering glyphs still marks the row hovered (drives the hover fill). + let pos = hover_row_text(&ctx, &mut tab, 0); + + // Left click on glyphs selects the row. + run_frame(&ctx, &mut tab, click(pos, egui::PointerButton::Primary)); + assert_eq!(tab.selected, Some(0)); + + // Right click on glyphs selects the row and opens the context menu. + let pos = hover_row_text(&ctx, &mut tab, 1); + run_frame(&ctx, &mut tab, click(pos, egui::PointerButton::Secondary)); + assert_eq!(tab.selected, Some(1)); + assert!(egui::Popup::is_any_open(&ctx)); + } +} diff --git a/crates/quicksearch-gui/src/ui_util.rs b/crates/quicksearch-gui/src/ui_util.rs new file mode 100644 index 0000000..c3a4cb3 --- /dev/null +++ b/crates/quicksearch-gui/src/ui_util.rs @@ -0,0 +1,129 @@ +//! Shared UI helpers: emphasis colors, bordered widgets, ignore-pattern +//! validation, and the "more content below" scroll hint. + +use quicksearch_core::config::IgnoreSet; + +/// Warning/emphasis orange, also used for the fuzzy-edit-distance warning. +pub const ORANGE: egui::Color32 = egui::Color32::from_rgb(220, 150, 40); +/// Emphasis blue for the primary commit controls. +pub const BLUE: egui::Color32 = egui::Color32::from_rgb(90, 150, 250); +/// Border of a pattern editor holding a valid pattern. +pub const VALID_GREEN: egui::Color32 = egui::Color32::from_rgb(80, 180, 100); +/// Border of a pattern editor holding an invalid pattern. +pub const INVALID_RED: egui::Color32 = egui::Color32::from_rgb(220, 80, 80); + +/// A standard button with a colored emphasis border. +pub fn bordered_button( + text: impl Into, + color: egui::Color32, +) -> egui::Button<'static> { + egui::Button::new(text).stroke(egui::Stroke::new(1.5, color)) +} + +/// Whether `pattern` is usable as an ignore pattern. `IgnoreSet::compile` +/// silently *skips* patterns that trim to nothing, so emptiness is checked +/// here with the same trimming rules compile applies. +pub fn ignore_pattern_valid(pattern: &str) -> bool { + let trimmed = pattern.trim().trim_end_matches(['/', '\\']); + !trimmed.is_empty() && IgnoreSet::compile(&[pattern.to_string()]).is_ok() +} + +/// Single-line ignore-pattern editor with a green border while the text is +/// a valid pattern and a red one otherwise. Returns the response and the +/// validity of the text as it stands after this frame's edits. +pub fn pattern_edit( + ui: &mut egui::Ui, + text: &mut String, + desired_width: f32, + hint: &str, +) -> (egui::Response, bool) { + let mut valid = ignore_pattern_valid(text); + let stroke = egui::Stroke::new(1.0, if valid { VALID_GREEN } else { INVALID_RED }); + let response = ui + .scope(|ui| { + // TextEdit frames with widgets.*.bg_stroke when unfocused and + // selection.stroke when focused; recolor all of them. + let v = ui.visuals_mut(); + v.widgets.inactive.bg_stroke = stroke; + v.widgets.hovered.bg_stroke = stroke; + v.widgets.active.bg_stroke = stroke; + v.selection.stroke = stroke; + ui.add( + egui::TextEdit::singleline(text) + .desired_width(desired_width) + .hint_text(hint), + ) + }) + .inner; + if response.changed() { + // The border catches up next frame; the returned validity is current. + valid = ignore_pattern_valid(text); + } + (response, valid) +} + +/// Paint a semitransparent down-arrow near the bottom edge of a scroll +/// area while more content lies below the fold. Painter-only on the +/// foreground layer, so it can never swallow clicks. (The bundled fonts +/// have no ▼ glyph — this is a shape, like the sort-header triangles.) +pub fn more_below_hint(ui: &egui::Ui, out: &egui::scroll_area::ScrollAreaOutput) { + let more_below = out.state.offset.y + out.inner_rect.height() < out.content_size.y - 1.0; + if !more_below { + return; + } + let painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("qs-more-below-hint"), + )); + let cx = out.inner_rect.center().x; + let tip = out.inner_rect.bottom() - 5.0; + let (half_width, height) = (7.0, 6.0); + // A slow opacity pulse so the hint reads as a cue rather than + // furniture. Repaints are only requested while the arrow is visible, + // and at a lazy cadence — the fade is too subtle to need 60 fps. + let t = ui.ctx().input(|i| i.time); + let pulse = 0.35 + 0.10 * ((t * std::f64::consts::TAU / 2.5).sin() as f32); + let color = ui.visuals().strong_text_color().gamma_multiply(pulse); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(50)); + painter.add(egui::Shape::convex_polygon( + vec![ + egui::pos2(cx - half_width, tip - height), + egui::pos2(cx + half_width, tip - height), + egui::pos2(cx, tip), + ], + color, + egui::Stroke::NONE, + )); +} + +#[cfg(test)] +mod tests { + use super::ignore_pattern_valid; + + #[test] + fn blank_patterns_are_invalid() { + // IgnoreSet::compile would silently skip all of these. + assert!(!ignore_pattern_valid("")); + assert!(!ignore_pattern_valid(" ")); + assert!(!ignore_pattern_valid("/")); + assert!(!ignore_pattern_valid("\\")); + assert!(!ignore_pattern_valid(" // ")); + } + + #[test] + fn malformed_globs_are_invalid() { + assert!(!ignore_pattern_valid("[")); + assert!(!ignore_pattern_valid("foo[")); // unclosed character class + } + + #[test] + fn usual_patterns_are_valid() { + assert!(ignore_pattern_valid("*.tmp")); // extension + assert!(ignore_pattern_valid("node_modules")); // name + assert!(ignore_pattern_valid("Thumbs.db")); + assert!(ignore_pattern_valid("/home/x/docs/*")); // directory + assert!(ignore_pattern_valid("C:\\Windows\\Temp\\*")); + assert!(ignore_pattern_valid("cache-??")); // wildcards + } +} diff --git a/crates/quicksearch-gui/src/unlock.rs b/crates/quicksearch-gui/src/unlock.rs new file mode 100644 index 0000000..2236e0b --- /dev/null +++ b/crates/quicksearch-gui/src/unlock.rs @@ -0,0 +1,458 @@ +//! Startup gate for password-protected indexes. +//! +//! [`Gate`] wraps the real app: while locked it renders a full-window +//! unlock screen and starts none of the backend (no coordinator, no +//! watcher, no database opens). Only after the password verifies — or the +//! keychain supplied a working key before the window even opened — is +//! [`QuickSearchApp`] constructed. +//! +//! Password hygiene: the typed password moves into a [`Zeroizing`] buffer +//! on submit, travels to a worker thread that derives the key and drops +//! it, and the visible text-field state (including egui's undo buffer) is +//! purged. Only the derived key crosses the channel back. + +use std::sync::mpsc; + +use quicksearch_core::config::{Config, SecurityConfig}; +use quicksearch_core::db; +use quicksearch_core::security::{derive_key, IndexKey}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::app::QuickSearchApp; +use crate::keychain; + +/// The application shell handed to eframe: locked (unlock screen) or +/// running (the real app). +pub enum Gate { + Locked(UnlockScreen), + Running(Box), +} + +impl Gate { + /// Start unlocked: protection is off, or the keychain already + /// provided a verified key. + pub fn running( + ctx: &egui::Context, + cfg: Config, + config_error: Option, + initial_query: Option, + ) -> Result { + QuickSearchApp::new(ctx, cfg, config_error, initial_query) + .map(|app| Gate::Running(Box::new(app))) + } + + pub fn locked(cfg: Config, config_error: Option, initial_query: Option) -> Gate { + Gate::Locked(UnlockScreen::new(cfg, config_error, initial_query)) + } +} + +impl eframe::App for Gate { + fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + match self { + Gate::Running(app) => app.update(ctx, frame), + Gate::Locked(screen) => { + if let Some(app) = screen.update(ctx) { + *self = Gate::Running(Box::new(app)); + } + } + } + } + + fn on_exit(&mut self, gl: Option<&eframe::glow::Context>) { + if let Gate::Running(app) = self { + app.on_exit(gl); + } + } +} + +/// Try to unlock with the keychain before any window exists. `true` means +/// the process key is installed and verified — skip the prompt entirely. +pub fn try_keychain_unlock(cfg: &Config) -> bool { + if !cfg.security.use_keychain || cfg.security.salt_bytes().is_err() { + return false; + } + let db_path = cfg.resolved_database_path(); + let hex = match keychain::load_key(&db_path.to_string_lossy()) { + Ok(Some(hex)) => hex, + Ok(None) => return false, + Err(e) => { + eprintln!("warning: {}", e); + return false; + } + }; + let Ok(key) = IndexKey::from_hex(&hex) else { + return false; + }; + db::set_process_key(Some(key)); + match db::verify_process_key(&db_path.to_string_lossy()) { + Ok(()) => true, + Err(_) => { + // Stale entry or missing/foreign database file: fall back to + // the prompt with a clean slate. + db::set_process_key(None); + false + } + } +} + +/// What the unlock screen is being used for. +enum Mode { + /// An index exists: the password must open it. + Unlock, + /// Protection is on but no index file exists yet — the typed password + /// (with confirmation) becomes the one the new index is built under. + Create, + /// `password_protected = true` but the salt is missing or corrupt; no + /// password can help. Only the reset escape hatch applies. + BrokenSalt(String), +} + +pub struct UnlockScreen { + cfg: Config, + config_error: Option, + initial_query: Option, + mode: Mode, + password: String, + confirm: String, + remember: bool, + error: Option, + /// In-flight Argon2 derivation (+ verification) on a worker thread. + job: Option>>, + forgot_confirm: bool, +} + +impl UnlockScreen { + fn new(cfg: Config, config_error: Option, initial_query: Option) -> UnlockScreen { + let mode = match cfg.security.salt_bytes() { + Err(e) => Mode::BrokenSalt(e), + Ok(_) => { + if cfg.resolved_database_path().exists() { + Mode::Unlock + } else { + Mode::Create + } + } + }; + let remember = cfg.security.use_keychain; + UnlockScreen { + cfg, + config_error, + initial_query, + mode, + password: String::new(), + confirm: String::new(), + remember, + error: None, + job: None, + forgot_confirm: false, + } + } + + /// Render one frame; `Some(app)` when the gate opens. + fn update(&mut self, ctx: &egui::Context) -> Option { + if let Some(result) = self.poll_job() { + match result { + Ok(key) => return self.unlocked(ctx, key), + Err(e) => { + self.error = Some(if e.starts_with(db::KEY_MISMATCH_PREFIX) { + "Wrong password.".to_string() + } else { + e + }); + } + } + } + + let mut submitted = false; + egui::CentralPanel::default().show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(ui.available_height() * 0.25); + ui.heading("QuickSearch"); + ui.add_space(12.0); + match &self.mode { + Mode::BrokenSalt(reason) => { + ui.colored_label(ui.visuals().error_fg_color, reason); + ui.label( + egui::RichText::new( + "The index cannot be unlocked with this configuration.", + ) + .small(), + ); + return; + } + Mode::Unlock => { + ui.label("The search index is password-protected."); + } + Mode::Create => { + ui.label("Password protection is enabled, but no index exists yet."); + ui.label( + egui::RichText::new( + "The new index will be encrypted with the password you \ + enter here.", + ) + .small() + .weak(), + ); + } + } + ui.add_space(8.0); + + let busy = self.job.is_some(); + ui.add_enabled_ui(!busy, |ui| { + let field = ui.add( + egui::TextEdit::singleline(&mut self.password) + .id(pw_field_id()) + .password(true) + .hint_text("Password") + .desired_width(240.0), + ); + if matches!(self.mode, Mode::Create) { + ui.add( + egui::TextEdit::singleline(&mut self.confirm) + .id(confirm_field_id()) + .password(true) + .hint_text("Confirm password") + .desired_width(240.0), + ); + } + ui.add_space(4.0); + ui.checkbox(&mut self.remember, "Remember on this device") + .on_hover_text( + "Stores the derived key (not the password) in the OS \ + keychain and skips this prompt.", + ); + ui.add_space(8.0); + + let label = match self.mode { + Mode::Unlock => "Unlock", + _ => "Create index", + }; + let clicked = ui.button(label).clicked(); + let entered = field.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)); + submitted = clicked || entered; + if !busy && !field.has_focus() && !submitted { + field.request_focus(); + } + }); + if busy { + ui.add_space(6.0); + ui.spinner(); + ui.label(egui::RichText::new("Deriving key…").small().weak()); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + if let Some(error) = &self.error { + ui.add_space(6.0); + ui.colored_label(ui.visuals().error_fg_color, error); + } + }); + + ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { + ui.add_space(16.0); + if !matches!(self.mode, Mode::Create) + && ui.small_button("Forgot password…").clicked() + { + self.forgot_confirm = true; + } + }); + }); + + if submitted && self.job.is_none() { + self.submit(ctx); + } + if self.forgot_confirm { + if let Some(app) = self.forgot_confirm_ui(ctx) { + return Some(app); + } + } + None + } + + fn poll_job(&mut self) -> Option> { + let rx = self.job.as_ref()?; + match rx.try_recv() { + Ok(result) => { + self.job = None; + Some(result) + } + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => { + self.job = None; + Some(Err("key derivation thread died".to_string())) + } + } + } + + /// Move the typed password off to the derivation thread and scrub the + /// UI-side buffers. + fn submit(&mut self, ctx: &egui::Context) { + self.error = None; + if matches!(self.mode, Mode::Create) { + if self.password.is_empty() { + self.error = Some("The password may not be empty.".to_string()); + return; + } + if self.password != self.confirm { + self.error = Some("Passwords do not match.".to_string()); + return; + } + } + let Ok(salt) = self.cfg.security.salt_bytes() else { + return; // BrokenSalt mode never reaches submit + }; + let password = Zeroizing::new(std::mem::take(&mut self.password)); + self.confirm.zeroize(); + self.confirm.clear(); + purge_text_state(ctx, pw_field_id()); + purge_text_state(ctx, confirm_field_id()); + + let verify_against = match self.mode { + Mode::Unlock => Some(self.cfg.resolved_database_path()), + _ => None, + }; + let (tx, rx) = mpsc::channel(); + let repaint = ctx.clone(); + std::thread::spawn(move || { + let key = derive_key(&password, &salt); + drop(password); + db::set_process_key(Some(key.clone())); + let result = match &verify_against { + Some(db_path) => { + db::verify_process_key(&db_path.to_string_lossy()).map(|()| key) + } + None => Ok(key), + }; + let _ = tx.send(result); + repaint.request_repaint(); + }); + self.job = Some(rx); + } + + /// The key verified (or a fresh index is being created): remember it if + /// asked, persist the keychain preference, and start the real app. + fn unlocked(&mut self, ctx: &egui::Context, key: IndexKey) -> Option { + let db_path = self.cfg.resolved_database_path(); + if self.remember { + if let Err(e) = keychain::store_key(&db_path.to_string_lossy(), &key.to_hex()) { + // Non-fatal: unlock proceeds, the preference just can't + // stick. Surface it in the running app's banner. + self.config_error = Some(e); + } + } else { + keychain::delete_key(&db_path.to_string_lossy()); + } + if self.cfg.security.use_keychain != self.remember { + self.cfg.security.use_keychain = self.remember; + if let Err(e) = self.cfg.save() { + self.config_error = Some(e); + } + } + self.launch(ctx) + } + + /// Construct the real app; on failure stay locked and show why. + fn launch(&mut self, ctx: &egui::Context) -> Option { + match QuickSearchApp::new( + ctx, + self.cfg.clone(), + self.config_error.take(), + self.initial_query.take(), + ) { + Ok(app) => Some(app), + Err(e) => { + self.error = Some(format!("Failed to start: {}", e)); + None + } + } + } + + /// "Forgot password" confirmation. The index is derived data: deleting + /// it and disabling protection loses nothing but time. `Some(app)` when + /// the reset happened and the app launched unprotected. + fn forgot_confirm_ui(&mut self, ctx: &egui::Context) -> Option { + let mut launched = None; + let mut close = false; + egui::Window::new("Reset the index?") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(420.0); + ui.label( + "Without the password the index cannot be read. This deletes \ + the index and turns password protection off. Your files are \ + not touched; the index is rebuilt by indexing again.", + ); + ui.horizontal(|ui| { + if ui + .button( + egui::RichText::new("Delete index & disable protection") + .color(ui.visuals().error_fg_color), + ) + .clicked() + { + let db_path = self.cfg.resolved_database_path(); + if let Err(e) = delete_index_files(&db_path) { + self.error = Some(e); + } else { + keychain::delete_key(&db_path.to_string_lossy()); + db::set_process_key(None); + self.cfg.security = SecurityConfig::default(); + if let Err(e) = self.cfg.save() { + self.config_error = Some(e); + } + launched = self.launch(ctx); + } + close = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + if close { + self.forgot_confirm = false; + } + launched + } +} + +impl Drop for UnlockScreen { + fn drop(&mut self) { + self.password.zeroize(); + self.confirm.zeroize(); + } +} + +fn pw_field_id() -> egui::Id { + egui::Id::new("unlock-password") +} + +fn confirm_field_id() -> egui::Id { + egui::Id::new("unlock-confirm") +} + +/// Drop egui's retained state for a password field — its text buffer and +/// undo history — so the plaintext doesn't outlive the submit. +fn purge_text_state(ctx: &egui::Context, id: egui::Id) { + ctx.data_mut(|d| d.remove::(id)); +} + +/// Delete the index and its WAL/SHM/journal sidecars. No coordinator +/// exists while the gate is locked, so plain filesystem deletes are safe. +fn delete_index_files(db_path: &std::path::Path) -> Result<(), String> { + match quicksearch_core::platform::remove_file_retrying(db_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("Failed to delete {}: {}", db_path.display(), e)), + } + for suffix in ["-wal", "-shm", "-journal"] { + let name = format!( + "{}{}", + db_path.file_name().and_then(|s| s.to_str()).unwrap_or(""), + suffix + ); + let _ = quicksearch_core::platform::remove_file_retrying(&db_path.with_file_name(name)); + } + Ok(()) +} diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index ee92843..d098570 100644 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -181,6 +181,11 @@ depends="$depends, hicolor-icon-theme" # the dbus-send used by "reveal in folder"; xdg-utils provides the xdg-open # fallback; xdg-desktop-portal backs the native folder picker. None are needed # to search, so none are hard dependencies. +# +# The index-password "remember on this device" feature talks Secret Service +# over the session bus with a statically linked libdbus (keyring's `vendored` +# feature), so it adds no library dependency; without a Secret Service +# provider (gnome-keyring, kwalletd) it simply falls back to prompting. recommends="desktop-file-utils, xdg-utils, dbus-bin, xdg-desktop-portal" install -dm755 "$stage/DEBIAN" diff --git a/packaging/quicksearch.1 b/packaging/quicksearch.1 index cefd9b7..b88e48f 100644 --- a/packaging/quicksearch.1 +++ b/packaging/quicksearch.1 @@ -83,6 +83,26 @@ Match a MIME type, for example .TP .B name:\fIfragment\fR Match a fragment of the filename. +.SH PASSWORD PROTECTION +The index can be encrypted with a password (application Options, Security). +A protected index must be unlocked every time either binary starts. The +application shows an unlock screen; terminal mode resolves the key from, in +order: the OS keychain (when \(lqRemember on this device\(rq is enabled), +the +.B QUICKSEARCH_PASSWORD +environment variable, then a hidden interactive prompt with three attempts. +Without a terminal and with neither source available it exits 2 with an +explanatory message. A wrong password never modifies the index. +.PP +Enabling, disabling or changing the password deletes and rebuilds the index +from the application; the password itself is never stored, only a derived +key in the OS keychain when explicitly requested. +.SH ENVIRONMENT +.TP +.B QUICKSEARCH_PASSWORD +Password for a protected index, for scripted terminal search. Environment +variables are readable by other processes of the same user; prefer the +keychain where possible. .SH FILES .TP .I ~/.config/quicksearch/config.toml @@ -116,8 +136,8 @@ was requested. The application failed to start. .TP .B 2 -The configuration could not be read, the index could not be opened, the query -was rejected, or +The configuration could not be read, the index could not be opened or +unlocked, the query was rejected, or .B quicksearch\-cli was given no query. .SH EXAMPLES diff --git a/run.bat b/run.bat deleted file mode 100644 index 9be5322..0000000 --- a/run.bat +++ /dev/null @@ -1,11 +0,0 @@ -@echo off -rem Build and launch the QuickSearch GUI, mirroring run.sh. -rem Terminal search is a separate binary on Windows, because the GUI is built -rem as a window-subsystem app and cannot write to the calling shell: -rem target\release\quicksearch-cli.exe --help -setlocal -cargo build --release -p quicksearch-gui -if errorlevel 1 exit /b 1 -rem %~dp0 is this script's own directory (with a trailing backslash), so the -rem launch does not depend on the current working directory. -"%~dp0target\release\quicksearch.exe" %* diff --git a/run.sh b/run.sh deleted file mode 100644 index a9eb1e2..0000000 --- a/run.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env sh -# Build and launch the QuickSearch GUI. On Unix the same binary doubles as the -# terminal search tool: ./target/release/quicksearch --help -# (On Windows that role belongs to quicksearch-cli.exe — see run.bat.) -set -e -cargo build --release -p quicksearch-gui -exec ./target/release/quicksearch "$@"