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:
parent
096c13e21a
commit
e63706b4b5
37 changed files with 4123 additions and 191 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -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
360
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>"]
|
||||
|
|
|
|||
93
README.md
93
README.md
|
|
@ -8,22 +8,40 @@ compact egui desktop app, or straight to your terminal.
|
|||
|
||||
## Build & run
|
||||
|
||||
Requirements: a Rust toolchain (edition 2021). The old WebKit/WebView
|
||||
dependencies (`setup.sh`) are gone; the GUI renders with OpenGL via egui.
|
||||
```sh
|
||||
./build.sh # Linux
|
||||
build.bat # Windows
|
||||
```
|
||||
|
||||
SQLite and zstd are compiled from bundled C sources, so a C toolchain is
|
||||
required on every platform:
|
||||
These take a fresh machine all the way to a running app: install whatever
|
||||
build dependencies are missing, build release, then launch the GUI. Each
|
||||
setup stage is skipped when what it provides is already there, so a normal
|
||||
run costs one `cargo build`. `build.sh` installs Linux system packages with
|
||||
the distribution's package manager via `sudo` (apt/dnf/pacman/zypper) and the
|
||||
Rust toolchain with rustup; `build.bat` uses winget and rustup. Both take
|
||||
`--check` to report dependency status without installing or building,
|
||||
`--no-run` to stop after the build, and `--` to pass the rest to the binary.
|
||||
|
||||
Building by hand needs a Rust toolchain (edition 2021) plus, on every
|
||||
platform, a C toolchain and Perl: SQLCipher, zstd and OpenSSL are compiled
|
||||
from bundled C sources, and OpenSSL's `Configure` is a Perl script. The old
|
||||
WebKit/WebView dependencies (`setup.sh`) are gone; the GUI renders with
|
||||
OpenGL via egui.
|
||||
|
||||
- Linux: working OpenGL 3.3 drivers; `xdg-desktop-portal` (present on all
|
||||
mainstream desktops) provides the native folder picker. On minimal
|
||||
images you may need `build-essential pkg-config libxkbcommon-dev`.
|
||||
images you may need `build-essential perl pkg-config`. No X11, Wayland or
|
||||
xkbcommon `-dev` packages are needed: winit dlopens the display stack at
|
||||
run time, so only the runtime libraries matter.
|
||||
- Windows: Visual Studio 2022 Build Tools with the "Desktop development
|
||||
with C++" workload (MSVC v143 plus a Windows SDK). For the GNU target
|
||||
instead, `rustup target add x86_64-pc-windows-gnu` and a mingw-w64
|
||||
toolchain. Note that Windows ships only a software OpenGL 1.1 driver, so
|
||||
a bare VM or an RDP session without a vendor GPU driver cannot create a
|
||||
context and the window will fail to open.
|
||||
- macOS: Xcode command line tools.
|
||||
with C++" workload (MSVC v143 plus a Windows SDK), and Perl (Strawberry
|
||||
Perl); NASM is optional and only enables OpenSSL's assembly paths. For the
|
||||
GNU target instead, `rustup target add x86_64-pc-windows-gnu` and a
|
||||
mingw-w64 toolchain. Note that Windows ships only a software OpenGL 1.1
|
||||
driver, so a bare VM or an RDP session without a vendor GPU driver cannot
|
||||
create a context and the window will fail to open.
|
||||
- macOS: Xcode command line tools (`build.sh` does not auto-install these —
|
||||
only Linux package managers are handled).
|
||||
|
||||
```sh
|
||||
cargo build --release -p quicksearch-gui # binaries: target/release/quicksearch{,-cli}
|
||||
|
|
@ -31,8 +49,6 @@ cargo run -p quicksearch-gui # or just run it
|
|||
cargo test -p quicksearch-core # backend test suite
|
||||
```
|
||||
|
||||
`run.sh` / `run.bat` wrap the same commands.
|
||||
|
||||
Two binaries are produced. `quicksearch` is the desktop app; on Windows it
|
||||
is built as a window-subsystem app so no console appears behind it.
|
||||
`quicksearch-cli` is terminal search — a console app, so pipes, redirection
|
||||
|
|
@ -95,10 +111,11 @@ the package is installed.
|
|||
|
||||
- **Search**: results appear as you type; every keystroke cancels the
|
||||
previous search. One checkbox enables the two fuzzy passes. Sort by
|
||||
rank, name, path, size, or modified; right-click a result to open it,
|
||||
reveal it in the file manager, or build an ignore filter from it
|
||||
(session-only by default, optionally persisted to the config). Matches
|
||||
in file contents show highlighted snippets.
|
||||
rank, name, path, size, or modified. Double-click a result to open it;
|
||||
right-click it to reveal it in the file manager, open it, copy its
|
||||
path, or build an ignore filter from it (session-only by default,
|
||||
optionally persisted to the config). Result text can be selected and
|
||||
copied in place. Matches in file contents show highlighted snippets.
|
||||
- **Manage Index**: full indexing status, Start/Stop/Automatic controls,
|
||||
indexed folder list, full-text extension filters, ignore patterns, and
|
||||
the indexing options.
|
||||
|
|
@ -108,6 +125,8 @@ the package is installed.
|
|||
filter box and Copy button. Launched from a desktop launcher (or on
|
||||
Windows, where the app has no console at all) this is the only place
|
||||
they are visible.
|
||||
- **Help**: an in-app quickstart — first indexing run, example queries,
|
||||
what each tab does — pointing here for everything technical.
|
||||
|
||||
The bottom status bar always shows what the indexer is doing (phase,
|
||||
percent, files/sec) or the total indexed file count when idle.
|
||||
|
|
@ -128,6 +147,37 @@ opens the app, and any query given to it seeds the search box instead of
|
|||
printing. Colour in `--long` output needs a console with virtual-terminal
|
||||
processing; Windows Terminal has it, and older consoles get plain text.
|
||||
|
||||
### Password protection
|
||||
|
||||
The index contains the names and (by default) the full text of everything
|
||||
it indexes — for most setups, your entire home directory. That is a lot of
|
||||
concentrated risk in one file. **Options → Security → Enable password
|
||||
protection** encrypts the index on disk with SQLCipher; from then on
|
||||
QuickSearch asks for the password every time it starts, in the GUI (an
|
||||
unlock screen before anything opens the index) and in the terminal (a
|
||||
hidden prompt). Enabling, disabling, or changing the password deletes and
|
||||
rebuilds the index — there is no in-place conversion.
|
||||
|
||||
- The key is derived as `Argon2id(password, salt)`; the salt is written to
|
||||
`config.toml` when the password is set (it is unique, not secret, and
|
||||
required — keep it with the config if you copy a protected setup).
|
||||
- **Remember on this device** stores the derived key (never the password)
|
||||
in the OS keychain — Secret Service/KWallet on Linux, Credential Manager
|
||||
on Windows — and skips the prompt. Without a keychain daemon the option
|
||||
quietly falls back to prompting.
|
||||
- Scripts can set `QUICKSEARCH_PASSWORD` for non-interactive terminal
|
||||
search. Environment variables are readable by other processes of the
|
||||
same user (`/proc/<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
214
build.bat
Normal 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
225
build.sh
Normal 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" "$@"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
45
crates/quicksearch-core/examples/build_protected_index.rs
Normal file
45
crates/quicksearch-core/examples/build_protected_index.rs
Normal 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);
|
||||
}
|
||||
415
crates/quicksearch-core/examples/memprobe.rs
Normal file
415
crates/quicksearch-core/examples/memprobe.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
37
crates/quicksearch-core/src/db/key.rs
Normal file
37
crates/quicksearch-core/src/db/key.rs
Normal 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())
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>)> = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
205
crates/quicksearch-core/src/security.rs
Normal file
205
crates/quicksearch-core/src/security.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
144
crates/quicksearch-core/tests/encrypted.rs
Normal file
144
crates/quicksearch-core/tests/encrypted.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
122
crates/quicksearch-gui/src/help_tab.rs
Normal file
122
crates/quicksearch-gui/src/help_tab.rs
Normal 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);
|
||||
}
|
||||
42
crates/quicksearch-gui/src/keychain.rs
Normal file
42
crates/quicksearch-gui/src/keychain.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
129
crates/quicksearch-gui/src/ui_util.rs
Normal file
129
crates/quicksearch-gui/src/ui_util.rs
Normal 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
|
||||
}
|
||||
}
|
||||
458
crates/quicksearch-gui/src/unlock.rs
Normal file
458
crates/quicksearch-gui/src/unlock.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
11
run.bat
|
|
@ -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
7
run.sh
|
|
@ -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 "$@"
|
||||
Loading…
Add table
Reference in a new issue