Added build scripts, improved search result click interactions, added a help tab, redesigned the ignore filter, added password protection for db.

This commit is contained in:
= 2026-08-02 20:21:19 -04:00
parent 096c13e21a
commit e63706b4b5
37 changed files with 4123 additions and 191 deletions

2
.gitattributes vendored
View file

@ -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

360
Cargo.lock generated
View file

@ -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"

View file

@ -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 <jeremy@karsttech.com>"]

View file

@ -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/<pid>/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,

214
build.bat Normal file
View file

@ -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 -- ^<args^> 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%

225
build.sh Normal file
View file

@ -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 -- <args> 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" "$@"

View file

@ -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

View file

@ -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"

View file

@ -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 -- \
//! <root> <db_path> <salt_hex> <password>
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<String> = std::env::args().skip(1).collect();
let [root, db_path, salt_hex, password] = args.as_slice() else {
eprintln!("usage: build_protected_index <root> <db_path> <salt_hex> <password>");
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);
}

View file

@ -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 <cold|warm> <root> <db> [sample_ms]");
std::process::exit(2);
};
if mode != "cold" && mode != "warm" {
eprintln!("usage: memprobe <cold|warm> <root> <db> [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<Sample> = 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<u64>,
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<u64> {
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<String, u64> = 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<String> {
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<u64> {
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))
}

View file

@ -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<String>,
/// 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<String> {
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

View file

@ -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<Option<IndexKey>> = 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<IndexKey>) {
*PROCESS_KEY.write().expect("process key lock poisoned") = key;
}
/// Snapshot of the current key for a single open.
pub(crate) fn process_key() -> Option<IndexKey> {
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<String> {
PROCESS_KEY
.read()
.expect("process key lock poisoned")
.as_ref()
.map(|k| k.to_hex())
}

View file

@ -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,
};

View file

@ -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<Connection, String> {
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<Connection, String> {
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<Connection, St
}
let conn = Connection::open(db_path)
.map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?;
key_and_probe(&conn, db_path, key)?;
conn.execute_batch(PRAGMAS_FAST)
.map_err(|e| format!("Failed to apply pragmas: {}", e))?;
@ -61,7 +77,7 @@ pub fn open_or_recreate(db_path: &str, tokenizer: &str) -> Result<Connection, St
Existing rows will be re-scanned on next indexing run.",
db_path
);
let conn = wipe_and_reopen(conn, &path)?;
let conn = wipe_and_reopen(conn, &path, key)?;
apply_current_schema(&conn, tokenizer)?;
Ok(conn)
}
@ -80,6 +96,14 @@ pub fn open_or_recreate(db_path: &str, tokenizer: &str) -> Result<Connection, St
/// indexer's own write path uses [`open_or_recreate`], which may wipe on a
/// genuine schema/tokenizer change it owns.
pub fn open_existing(db_path: &str, write: bool) -> Result<Connection, String> {
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<Connection, String> {
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<Connection, String> {
};
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<Connection, String> {
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<bool, String
}
/// Drop the current connection, delete the DB file + its WAL/SHM/journal
/// sidecars, reopen a fresh file, re-apply pragmas.
fn wipe_and_reopen(conn: Connection, path: &Path) -> Result<Connection, String> {
/// 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<Connection, String> {
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<Connection, String>
}
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<u8> {
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();

View file

@ -32,7 +32,8 @@ pub fn load_existing_files(conn: &Connection) -> Result<HashMap<String, Existing
Ok((
row.get::<_, String>(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<ExtractScope, String> {
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<String>)> = {

View file

@ -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;

View file

@ -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:"));
}
}

View file

@ -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(<redacted>)")
}
}
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<IndexKey, String> {
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<Vec<u8>, 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(<redacted>)");
assert!(!dbg.contains(&key.to_hex()));
}
}

View file

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

View file

@ -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",

View file

@ -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<WatchError>,
/// In-flight security flow (enable/disable/change password), driven by
/// the Options window's Security block.
security_prompt: Option<SecurityPrompt>,
config_error: Option<String>,
}
/// 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<IndexKey>,
},
}
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<String>,
initial_query: Option<String>,
) -> Result<QuickSearchApp, String> {
// 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<IndexKey>) {
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::text_edit::TextEditState>(egui::Id::new("security-pw1"));
d.remove::<egui::text_edit::TextEditState>(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);

View file

@ -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<i32> {
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<Zeroizing<String>>,
keychain_hex: Option<String>,
mut prompt: impl FnMut() -> Option<Zeroizing<String>>,
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<usize>, long: bool) -> i32 {
let config = match Config::load() {
Ok(c) => c,
@ -98,6 +213,19 @@ fn run_query(query: &str, fuzzy: bool, limit: Option<usize>, 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"));
}
}

View file

@ -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

View file

@ -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

View file

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

View file

@ -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, String> {
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<Option<String>, 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();
}
}

View file

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

View file

@ -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<dyn eframe::App>)
.map_err(|e| e.into())
let gate = if start_unlocked {
unlock::Gate::running(&cc.egui_ctx, config, config_error, initial_query)
.map_err(Box::<dyn std::error::Error + Send + Sync>::from)?
} else {
unlock::Gate::locked(config, config_error, initial_query)
};
Ok(Box::new(gate) as Box<dyn eframe::App>)
}),
);
if let Err(e) = result {

View file

@ -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<String>,
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<Config>,
/// Draft of the roots/filters/indexing knobs edited in-place.
draft: Option<Config>,
}
@ -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<usize> = 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);
}
}

View file

@ -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<Config>,
/// A Security block action was clicked.
pub security: Option<SecurityAction>,
}
pub struct OptionsWindow {
pub open: bool,
draft: Option<Config>,
@ -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<Config> {
/// 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<SecurityAction> {
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),
);
}
});

View file

@ -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<String>,
pub name_pattern: String,
pub dir_pattern: String,
pub persist: bool,
}
@ -69,6 +71,11 @@ pub struct SearchTab {
pub ignore_dialog: Option<IgnoreDialog>,
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<usize>,
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<usize> = None;
let mut hovered_now: Option<usize> = 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<egui::Response> = 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<String> = 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::Event>,
) -> 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<egui::Event> {
[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));
}
}

View file

@ -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<egui::WidgetText>,
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<R>(ui: &egui::Ui, out: &egui::scroll_area::ScrollAreaOutput<R>) {
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
}
}

View file

@ -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<QuickSearchApp>),
}
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<String>,
initial_query: Option<String>,
) -> Result<Gate, String> {
QuickSearchApp::new(ctx, cfg, config_error, initial_query)
.map(|app| Gate::Running(Box::new(app)))
}
pub fn locked(cfg: Config, config_error: Option<String>, initial_query: Option<String>) -> 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<String>,
initial_query: Option<String>,
mode: Mode,
password: String,
confirm: String,
remember: bool,
error: Option<String>,
/// In-flight Argon2 derivation (+ verification) on a worker thread.
job: Option<mpsc::Receiver<Result<IndexKey, String>>>,
forgot_confirm: bool,
}
impl UnlockScreen {
fn new(cfg: Config, config_error: Option<String>, initial_query: Option<String>) -> 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<QuickSearchApp> {
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<Result<IndexKey, String>> {
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<QuickSearchApp> {
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<QuickSearchApp> {
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<QuickSearchApp> {
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::<egui::text_edit::TextEditState>(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(())
}

View file

@ -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"

View file

@ -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

11
run.bat
View file

@ -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" %*

7
run.sh
View file

@ -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 "$@"