Storage, safety, and correctness hardening

This commit is contained in:
Jeremy Karst 2026-08-20 02:34:08 -04:00
parent 65080cdf17
commit 2e2936b249
65 changed files with 4177 additions and 838 deletions

340
Cargo.lock generated
View file

@ -86,7 +86,7 @@ dependencies = [
"android-properties",
"bitflags 2.13.1",
"cc",
"jni 0.22.4",
"jni",
"libc",
"log",
"ndk",
@ -501,9 +501,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.6.0"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "calloop"
@ -577,12 +577,6 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfb"
version = "0.7.3"
@ -745,16 +739,6 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.6"
@ -768,7 +752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-foundation",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
@ -781,7 +765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-foundation",
"libc",
]
@ -814,9 +798,9 @@ dependencies = [
[[package]]
name = "crossbeam-channel"
version = "0.5.13"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
@ -1261,11 +1245,10 @@ dependencies = [
[[package]]
name = "event-listener"
version = "5.3.1"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
@ -1429,15 +1412,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.30"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-io"
version = "0.3.30"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
@ -1454,26 +1437,26 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.30"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
"syn 3.0.3",
]
[[package]]
name = "futures-task"
version = "0.3.30"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.30"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-io",
@ -1481,7 +1464,6 @@ dependencies = [
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
@ -1764,21 +1746,23 @@ dependencies = [
[[package]]
name = "icu_collections"
version = "1.5.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locid"
version = "1.5.0"
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
@ -1787,109 +1771,81 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_locid_transform"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
dependencies = [
"displaydoc",
"icu_locid",
"icu_locid_transform_data",
"icu_provider",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locid_transform_data"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
[[package]]
name = "icu_normalizer"
version = "1.5.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"displaydoc",
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"utf16_iter",
"utf8_iter",
"write16",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "1.5.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "1.5.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f8ac670d7422d7f76b32e17a5db556510825b29ec9154f235977c9caba61036"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locid_transform",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"tinystr",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "1.5.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "1.5.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
dependencies = [
"displaydoc",
"icu_locid",
"icu_provider_macros",
"stable_deref_trait",
"tinystr",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_provider_macros"
version = "1.5.0"
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna"
version = "1.0.0"
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4716a3a0933a1d01c2f72450e89596eb51dd34ef3c211ccd875acdf1f8fe47ed"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
"smallvec",
"utf8_iter",
]
[[package]]
@ -1980,22 +1936,6 @@ version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.0",
"log",
"thiserror 1.0.61",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
@ -2191,9 +2131,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.7.3"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
@ -2964,12 +2904,6 @@ version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.3"
@ -3045,6 +2979,15 @@ version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306"
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.17"
@ -3087,15 +3030,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@ -3107,7 +3041,7 @@ dependencies = [
[[package]]
name = "quicksearch-core"
version = "1.1.0"
version = "1.1.2"
dependencies = [
"argon2",
"cfb",
@ -3124,7 +3058,7 @@ dependencies = [
"mime_guess",
"notify",
"pdf-extract",
"quick-xml 0.31.0",
"quick-xml",
"regex",
"rtf-parser",
"rusqlite",
@ -3140,7 +3074,7 @@ dependencies = [
[[package]]
name = "quicksearch-gui"
version = "1.1.0"
version = "1.1.2"
dependencies = [
"ashpd",
"chrono",
@ -3164,9 +3098,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.36"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@ -3864,9 +3798,9 @@ dependencies = [
[[package]]
name = "tinystr"
version = "0.7.6"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
@ -4058,9 +3992,9 @@ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "url"
version = "2.5.1"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c25da092f0a868cdf09e8674cd3b7ef3a7d92a24253e663a2fb85e2496de56"
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
dependencies = [
"form_urlencoded",
"idna",
@ -4074,12 +4008,6 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf16_iter"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@ -4305,7 +4233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quick-xml",
"quote",
]
@ -4343,15 +4271,15 @@ dependencies = [
[[package]]
name = "webbrowser"
version = "1.0.6"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97"
checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f"
dependencies = [
"core-foundation 0.10.1",
"jni 0.21.1",
"jni",
"log",
"ndk-context",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
"web-sys",
@ -4550,15 +4478,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@ -4604,21 +4523,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@ -4667,12 +4571,6 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@ -4691,12 +4589,6 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@ -4715,12 +4607,6 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@ -4751,12 +4637,6 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@ -4775,12 +4655,6 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@ -4799,12 +4673,6 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@ -4823,12 +4691,6 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@ -4862,7 +4724,7 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
"core-foundation",
"core-graphics",
"cursor-icon",
"dpi",
@ -4922,17 +4784,11 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
[[package]]
name = "writeable"
version = "0.5.5"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "x11-dl"
@ -5009,11 +4865,10 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "yoke"
version = "0.7.4"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"serde",
"stable_deref_trait",
"yoke-derive",
"zerofrom",
@ -5021,9 +4876,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.7.4"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
@ -5115,18 +4970,18 @@ dependencies = [
[[package]]
name = "zerofrom"
version = "0.1.4"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.4"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
@ -5155,10 +5010,21 @@ dependencies = [
]
[[package]]
name = "zerovec"
version = "0.10.2"
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb2cc8827d6c0994478a15c53f374f46fbd41bea663d809b14744bc42e6b109c"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
@ -5167,13 +5033,13 @@ dependencies = [
[[package]]
name = "zerovec-derive"
version = "0.10.2"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97cf56601ee5052b4417d90c8755c6683473c926039908196cf35d99f893ebe7"
checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
"syn 3.0.3",
]
[[package]]

View file

@ -31,7 +31,7 @@ exclude = ["vendor/pdf-extract"]
pdf-extract = { path = "vendor/pdf-extract" }
[workspace.package]
version = "1.1.1"
version = "1.1.2"
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Jeremy <jeremy@karsttech.com>"]

View file

@ -223,7 +223,16 @@ inside that folder.
### GUI
`quicksearch` with no query arguments opens the app:
`quicksearch` with no query arguments opens the app. **One window at a
time**: a second launch reports that QuickSearch is already running and
exits, because two processes indexing one database corrupt it. Terminal
search (`quicksearch <query>`) only reads and keeps working while the
window is open. The guard is a kernel lock on `<database_path>.lock`, held
by the running process — so a crash or a power cut releases it, and the
leftover file never locks you out. The lock follows `database_path`: point
Settings at a different index and it moves with you, and if that index
belongs to another instance the change is refused rather than written, so a
path you cannot open can never end up in the config file.
- **Search**: results appear as you type; every keystroke cancels the
previous search. One checkbox enables the two fuzzy passes, and once a
@ -491,7 +500,14 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
`wal_checkpoint(TRUNCATE)` every `processing.maximum_wal_size` bytes of
log, because SQLite's own autocheckpoint can only reset the log at an
instant no reader holds it — and a run keeps a reader per root querying
throughout, so left alone the log grows for the whole run. `files` holds
throughout, so left alone the log grows for the whole run. That threshold
is lowered to fit the volume when free space is short, and a run stops
with an error rather than fill the disk: SQLite reaches the wal-index
through an mmap, and a page fault the filesystem cannot back arrives as
**SIGBUS**, which no `Result` can catch. The index's own files are, for
the same reason, never walked into — hashing one means opening it, and on
POSIX closing any descriptor on an inode cancels every advisory lock the
process holds on it, SQLite's documented corruption hazard. `files` holds
metadata (name, path, size, mtime, hash, MIME/type bitmask, content
state); `searchabletext` is a *contentless* FTS5 table over one column,
the document body (postings only, configurable tokenizer, trigram by
@ -572,7 +588,7 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
tokenizing can do; the scheduling shares it fairly and keeps the walk
first, it does not raise it. Every run ends — whether
it completed or was stopped — with an optimize pass on its own connection:
checkpoint, VACUUM if the file has at least 10% slack to reclaim, `PRAGMA
checkpoint, VACUUM if the file has at least 20% slack to reclaim, `PRAGMA
optimize`, checkpoint again. Progress streams through a polled
`IndexingStatus`, which reads `Optimizing` for the duration of that pass —
and `Preparing` for everything a run does before its first file is walked:
@ -674,13 +690,15 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
to that by construction, because a buffer allocated and freed inside one loop
iteration never moves RSS. `DocDecoder` must therefore never fall back to a
per-row allocating decode, and there is a trap waiting there:
`zstd::encode_all`, which the indexer writes with, is *stream*-based and so
records no content size in the frame header, meaning
`get_frame_content_size` returns `None` for every row this ever sees. The
"cannot happen" branch is the only branch. Sizing the buffer from the header
and handing the `None` case to `zstd::decode_all` looks obviously right and
costs ~2.4 MiB per document, because `decode_all` builds a streaming decoder
per call — 27 of the 30 GiB a fuzzy search moved. Growing this buffer and
the indexer writes through `DocEncoder`, i.e. `zstd::bulk::Compressor` and
so `ZSTD_compress2`, which is handed the whole document at once and records
its length in the frame header. `get_frame_content_size` therefore returns
`Some` for every row this writer produced, the reservation is normally
exact, and the growth loop below it is the fallback for a frame written by
a *stream* encoder rather than the only branch. What must not come back is
handing the `None` case to `zstd::decode_all`, which looks obviously right
and costs ~2.4 MiB per document because it builds a streaming decoder per
call — 27 of the 30 GiB a fuzzy search moved. Growing this buffer and
keeping it is what makes decoding a row allocate nothing at all.
Measured over the same 77k-file index, per query:
`cascade` 582 → 14 MiB, `function` 6.0 GiB → 29 MiB, `--fuzzy cascade`

View file

@ -74,6 +74,11 @@ content_extensions = []
# The Windows defaults add: "$RECYCLE.BIN", "System Volume Information",
# "pagefile.sys", "hiberfil.sys", "swapfile.sys", "Thumbs.db",
# "desktop.ini".
#
# The index itself (database_path and its -wal/-shm/-journal/.lock
# siblings) is always skipped and needs no pattern here — not as a
# convenience but because merely opening one of those files to hash it
# would cancel SQLite's locks on it for the whole process.
ignore_patterns = [".git", "node_modules", "*.tmp", ".venv", "venv"]
# Worth adding by hand if you index a whole Windows drive rather than just
@ -124,6 +129,13 @@ fts_update_batch_size = 1000
# unattended the log grows for the whole run and can end up larger than the
# index. Set to 0 to disable forced checkpoints; any other value below
# 16777216 is raised to it.
#
# This is a stall-frequency knob, not a safety one: when the volume holding
# the index is short on space the indexer checkpoints sooner than asked —
# including when this is 0 — and stops the run with an error rather than
# fill the disk. Running out is not a clean failure; SQLite reaches its
# wal-index through a memory map, and a write the filesystem cannot back
# kills the process with SIGBUS instead of returning an error.
maximum_wal_size = 536870912
# FTS5 tokenizer: 'trigram' (substring matching, the default; gets
# remove_diacritics 1 appended), 'unicode61', 'porter', or a full FTS5

View file

@ -28,7 +28,7 @@ walkdir = "2.5.0"
# containers (docx/xlsx/pptx/odt/ods/odp), whose entries are always deflate or
# stored, so zstd/bzip2/aes-crypto are all dead weight here.
zip = { version = "0.6", default-features = false, features = ["deflate"] }
quick-xml = "0.31"
quick-xml = "0.41"
# OLE2 compound-file reader, for the pre-2007 binary Office formats
# (.doc/.xls/.ppt) whose text lives in named streams rather than a zip. Already
# in the lockfile transitively via infer, so naming it directly compiles
@ -101,6 +101,10 @@ windows-sys = { version = "0.52", features = [
# the directory-count path passes null for it.
"Win32_Security",
"Win32_Storage_FileSystem",
# OVERLAPPED, which `LockFileEx` takes for the index's instance lock.
# `LockFileEx` and `GetDiskFreeSpaceExW` themselves are in
# Win32_Storage_FileSystem above.
"Win32_System_IO",
"Win32_System_Threading",
"Win32_System_WindowsProgramming",
] }

View file

@ -80,7 +80,7 @@ fn serial(root: &str, config: &Config, existing: &DirRows) -> (usize, usize) {
for entry in filtered_walk(root, false, false, &ignore, &UnreadableDirs::default()) {
seen += 1;
// Same rule as the real walk: a name that is not valid UTF-8 cannot be
// stored in `files.path` and reopened by it, so it is skipped before
// stored in the index and reopened by its path, so it is skipped before
// anything tries to hash it. Counted as seen, never prepared.
let Some(path) = entry.path().to_str().map(str::to_owned) else {
continue;

View file

@ -103,17 +103,23 @@ impl SizeReport {
/// failure reason comes from `failed_files`, the one place it is written.
pub fn status_for_path(db_path: &str, path: &str) -> Result<FileStatus, String> {
let conn = open_existing(db_path, false)?;
let row: Option<(i64, Option<String>)> = conn
let split = crate::file_handling::split_db_path(path);
let row: Option<(i64, Option<String>)> = match split {
// Not a path a row could ever have been stored under, so it is not
// indexed — the same answer as a miss.
None => None,
Some((parent, name)) => conn
.query_row(
"SELECT f.content_state, ff.reason \
FROM files f \
LEFT JOIN failed_files ff ON ff.file_id = f.id \
WHERE f.path = ?1",
params![path],
WHERE f.parent = ?1 AND f.name = ?2",
params![parent, name],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
.map_err(|e| format!("status_for_path({}): {}", path, e))?;
.map_err(|e| format!("status_for_path({}): {}", path, e))?,
};
Ok(match row {
None => FileStatus {
path: path.to_string(),
@ -138,7 +144,7 @@ pub fn list_failed(db_path: &str, limit: Option<u32>) -> Result<Vec<FailedEntry>
None => String::new(),
};
let sql = format!(
"SELECT ff.file_id, f.path, ff.reason, ff.ts \
"SELECT ff.file_id, f.parent, f.name, ff.reason, ff.ts \
FROM failed_files ff \
JOIN files f ON f.id = ff.file_id \
ORDER BY ff.ts DESC{}",
@ -149,11 +155,13 @@ pub fn list_failed(db_path: &str, limit: Option<u32>) -> Result<Vec<FailedEntry>
.map_err(|e| format!("list_failed prepare: {}", e))?;
let rows = stmt
.query_map([], |r| {
let parent: String = r.get(1)?;
let name: String = r.get(2)?;
Ok(FailedEntry {
file_id: r.get(0)?,
path: r.get(1)?,
reason: r.get(2)?,
ts: r.get(3)?,
path: format!("{}{}", parent, name),
reason: r.get(3)?,
ts: r.get(4)?,
})
})
.map_err(|e| format!("list_failed query: {}", e))?;
@ -252,8 +260,7 @@ mod tests {
&tx,
&NewFile {
name: "a.txt",
path: "/tmp/a.txt",
parent: "/tmp",
parent: "/tmp/",
size: 1,
mtime: 1,
mime: Some("text/plain"),
@ -269,8 +276,7 @@ mod tests {
&tx,
&NewFile {
name: "b.bin",
path: "/tmp/b.bin",
parent: "/tmp",
parent: "/tmp/",
size: 1,
mtime: 1,
mime: None,
@ -343,8 +349,7 @@ mod tests {
&tx,
&NewFile {
name: "c.txt",
path: "/tmp/c.txt",
parent: "/tmp",
parent: "/tmp/",
size: 1,
mtime: 1,
mime: Some("text/plain"),
@ -361,8 +366,7 @@ mod tests {
&tx,
&NewFile {
name: "d.bin",
path: "/tmp/d.bin",
parent: "/tmp",
parent: "/tmp/",
size: 1,
mtime: 1,
mime: None,
@ -422,8 +426,7 @@ mod tests {
&tx,
&NewFile {
name: "big.txt",
path: "/tmp/big.txt",
parent: "/tmp",
parent: "/tmp/",
size: 1,
mtime: 1,
mime: Some("text/plain"),
@ -477,13 +480,12 @@ mod tests {
{
let mut conn = open_or_recreate(dbp, "unicode61").unwrap();
let tx = conn.transaction().unwrap();
for (name, path) in [("a.txt", "/tmp/a.txt"), ("b.txt", "/tmp/b.txt")] {
for name in ["a.txt", "b.txt"] {
insert_file(
&tx,
&NewFile {
name,
path,
parent: "/tmp",
parent: "/tmp/",
size: 1,
mtime: 1,
mime: Some("text/plain"),

View file

@ -15,7 +15,7 @@ use super::*;
/// app was closed. See [`crate::scope`] for the pass that applies it.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct IndexWork {
/// Roots that are no longer configured, in `files.path` spelling. Every
/// Roots that are no longer configured, in stored spelling. Every
/// row beneath one is deleted; no filesystem access is involved, so a
/// root whose folder is gone is handled the same as one that still
/// exists.

View file

@ -567,13 +567,37 @@ impl Config {
// open again. `rename` is atomic on both platforms, and the `sync_all`
// before it means the bytes are on the disk before the name points at
// them.
let tmp = path.with_extension("toml.tmp");
write_private(&tmp, content.as_bytes())
.map_err(|e| format!("Failed to write config file {}: {}", tmp.display(), e))?;
let tmp = write_private_temp(&path, content.as_bytes()).map_err(|e| {
format!(
"Failed to write config file beside {}: {}",
path.display(),
e
)
})?;
fs::rename(&tmp, &path).map_err(|e| {
let _ = fs::remove_file(&tmp);
format!("Failed to replace config file {}: {}", path.display(), e)
})?;
// The rename is atomic but not durable: it is a directory entry, and
// the directory has its own dirty state. Without this a power cut just
// after can leave *neither* name, which is the salt loss the whole
// dance exists to prevent. Best-effort, and on Windows it is a no-op
// every time rather than only on an exotic filesystem: opening a
// directory as a file needs `FILE_FLAG_BACKUP_SEMANTICS`, which
// `fs::File::open` does not pass, so this fails and is skipped. NTFS
// journals the rename itself, which is the guarantee this is reaching
// for; on Unix it has to be asked for.
if let Some(dir) = path.parent() {
if let Ok(handle) = fs::File::open(dir) {
let _ = handle.sync_all();
}
// Leftovers from a save that died between `create_new` and the
// rename. The old fixed `config.toml.tmp` overwrote itself, so
// there was never more than one; a unique name per attempt is what
// makes the write safe (see `write_private_temp`) and what makes
// them accumulate, so they are swept here instead.
sweep_stale_temps(dir, &path);
}
Ok(())
}
@ -598,8 +622,72 @@ impl Config {
}
}
/// Whether `path` is the index itself — the database, one of SQLite's
/// `-wal`/`-shm`/`-journal` sidecars, or the instance lock.
///
/// For the paths that arrive from a `files` row rather than from a walk:
/// once the walk stops indexing the index
/// ([`crate::file_handling::index_file_set`], which explains why opening
/// one is fatal), such rows are swept away, but a row written by an older
/// build survives until the sweep reaches it and can still be opened from
/// a result list in the meantime.
///
/// The name is compared first and the directory only on a hit, so the
/// overwhelmingly common miss costs one string comparison rather than the
/// `canonicalize` the directory check needs.
///
/// Names are folded where the filesystem folds them
/// ([`crate::platform::PATHS_ARE_CASE_INSENSITIVE`]): a stored row spelling
/// the database `Index.sqlite` names the same file as a config spelling it
/// `index.sqlite`, and opening it has the same consequence. ASCII only,
/// matching `PATH_COLLATION` and [`IgnoreSet`].
pub fn is_index_file(&self, path: &Path) -> bool {
let db = self.resolved_database_path();
let (Some(db_name), Some(name)) = (
db.file_name().and_then(|s| s.to_str()),
path.file_name().and_then(|s| s.to_str()),
) else {
return false;
};
let same_name = |a: &str, b: &str| {
if crate::platform::PATHS_ARE_CASE_INSENSITIVE {
a.eq_ignore_ascii_case(b)
} else {
a == b
}
};
// `str::get`, not `name[..cut]`: the cut is `db_name`'s *byte* length
// and the two names are unrelated strings, so it can land inside a
// multi-byte character — `€xyz` against a two-byte `db_name` is six
// bytes either way — and indexing there panics. A non-boundary is
// simply not a match.
let name_matches = same_name(name, db_name)
|| crate::file_handling::INDEX_SIDECAR_SUFFIXES
.iter()
.any(|s| {
let cut = db_name.len();
name.len() == cut + s.len()
&& name.get(..cut).is_some_and(|head| same_name(head, db_name))
&& name.get(cut..).is_some_and(|tail| same_name(tail, s))
});
if !name_matches {
return false;
}
let same_dir = |a: &Path, b: &Path| {
a == b
|| a.canonicalize().unwrap_or_else(|_| a.to_path_buf())
== b.canonicalize().unwrap_or_else(|_| b.to_path_buf())
};
match (path.parent(), db.parent()) {
(Some(a), Some(b)) => same_dir(a, b),
// Both at a filesystem root, or neither: the name match stands.
(None, None) => true,
_ => false,
}
}
/// `resolved_indexing_paths` canonicalized and spelled the way
/// `files.path` prefixes them.
/// stored parents are prefixed with them.
///
/// The form roots must be compared in: `~/docs`, `docs` in a portable
/// config and `/home/me/docs` are one root under three spellings, and a
@ -631,30 +719,121 @@ impl Config {
}
}
/// Write `bytes` to `path`, owner-readable only, and flush them to the disk
/// before returning.
/// Write `bytes` to a fresh temporary file beside `target`, owner-only, with
/// its contents flushed to the disk, and return its path for the caller to
/// rename into place.
///
/// `O_NOFOLLOW` on Unix: the config directory is not always somewhere only
/// this user can write — a portable install can sit in a shared or removable
/// directory — and a symlink left at the config's name would otherwise
/// redirect this write onto whatever it points at.
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
/// `create_new` and a unique name, not a fixed one: `O_NOFOLLOW` refuses a
/// symlink but says nothing about a *regular* file or a hardlink that is
/// already sitting at the name we were going to use. The config directory is
/// not always somewhere only this user can write — a portable install can sit
/// in a shared or removable directory — and an attacker who pre-creates the
/// temp file as a hardlink to a file they can read would otherwise be handed
/// `[security].salt`. `mode(0o600)` only applies to a file this call creates,
/// which is the same reason.
fn write_private_temp(target: &Path, bytes: &[u8]) -> std::io::Result<PathBuf> {
use std::io::Write;
let dir = target.parent().unwrap_or_else(|| Path::new("."));
let stem = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "config.toml".to_string());
// A handful of attempts, then give up rather than spin: if something is
// racing us for every name we pick, failing the save is the honest
// outcome — the caller treats a failed save as fatal to the change.
let mut last_err = None;
for _ in 0..8 {
let tmp = dir.join(format!("{}.{}.tmp", stem, unique_suffix()));
let mut opts = fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(bytes)?;
// The rename that follows is atomic with respect to the *directory*, not
// to the file's contents: without this, a crash can leave the new name
// pointing at a block of zeroes.
f.sync_all()
match opts.open(&tmp) {
Ok(mut f) => {
let wrote = f.write_all(bytes).and_then(|()| f.sync_all());
return match wrote {
Ok(()) => Ok(tmp),
Err(e) => {
let _ = fs::remove_file(&tmp);
Err(e)
}
};
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last_err = Some(e),
Err(e) => return Err(e),
}
}
Err(last_err.unwrap_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"could not create a temporary config file",
)
}))
}
/// How old an abandoned temp file must be before [`sweep_stale_temps`] takes
/// it. A save is a serialize, a write and a rename — milliseconds — so an hour
/// is far past any doubt, while still short enough that leftovers do not
/// accumulate across a run of crashes.
const TEMP_SWEEP_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60);
/// Delete abandoned `<config name>.*.tmp` files beside the config.
///
/// [`write_private_temp`] must use a *unique* name — a fixed one can be
/// pre-created by someone else as a hardlink — and a unique name is one that
/// nothing later overwrites, so a process that dies between `create_new` and
/// the rename leaves its temp file behind for good. The old fixed
/// `config.toml.tmp` was reused by the next save and so never accumulated;
/// this is what replaces that property.
///
/// Age is the discriminator, not the recorded PID: a temp file created seconds
/// ago may belong to a save running *right now* in another process, and
/// deleting that would destroy the very write this whole dance protects.
/// Entirely best-effort — a directory that cannot be listed is not a reason to
/// fail a save that already succeeded.
fn sweep_stale_temps(dir: &Path, target: &Path) {
let Some(stem) = target.file_name().and_then(|n| n.to_str()) else {
return;
};
// The trailing dot matters: without it the config file itself would be a
// prefix match.
let prefix = format!("{}.", stem);
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
continue;
}
let abandoned = entry
.metadata()
.and_then(|m| m.modified())
.is_ok_and(|t| t.elapsed().is_ok_and(|age| age >= TEMP_SWEEP_AGE));
if abandoned {
let _ = fs::remove_file(entry.path());
}
}
}
/// A short, non-guessable-enough suffix for the temp name.
///
/// This does not need to be unpredictable to an attacker — `create_new` is
/// what makes the write safe — only unlikely to collide with a leftover from
/// an interrupted save, so the process id and a clock reading are plenty.
fn unique_suffix() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
format!("{}-{}", std::process::id(), nanos)
}
/// Reserved `content_extensions` entry standing for "files with no

View file

@ -1043,3 +1043,55 @@ fn the_search_table_ships_without_size_or_modified() {
assert_eq!(older.search.display_limit, 500);
assert!(older.search.live_results, "live results default to on");
}
/// The guard the live watcher and duplicate verification consult before
/// opening a path that came from a `files` row. See
/// [`crate::file_handling::index_file_set`] for what opening one costs.
#[test]
fn the_index_and_every_sidecar_are_recognised() {
let mut c = Config::default();
c.paths.database_path = "/var/lib/qs/index.sqlite".to_string();
for name in [
"index.sqlite",
"index.sqlite-wal",
"index.sqlite-shm",
"index.sqlite-journal",
"index.sqlite.lock",
] {
let p = PathBuf::from(format!("/var/lib/qs/{}", name));
assert!(c.is_index_file(&p), "{} should be recognised", name);
}
// A near miss on the name, and the right name in the wrong directory.
for p in [
"/var/lib/qs/index.sqlite-walrus",
"/var/lib/qs/notes-wal",
"/var/lib/qs/index.sqlite2",
"/var/lib/other/index.sqlite-wal",
"/var/lib/qs/sub/index.sqlite",
] {
assert!(
!c.is_index_file(Path::new(p)),
"{} is not part of the index",
p
);
}
}
/// `database_path` is resolved the same way everywhere else resolves it, so a
/// `~` or a relative spelling has to compare equal to the absolute file.
#[test]
fn a_tilde_database_path_still_matches_the_real_file() {
let Some(home) = crate::platform::home_dir() else {
return;
};
let mut c = Config::default();
c.paths.database_path = "~/.local/share/quicksearch/index.sqlite".to_string();
let absolute = PathBuf::from(home).join(".local/share/quicksearch/index.sqlite");
assert!(c.is_index_file(&absolute));
let wal = absolute.with_file_name("index.sqlite-wal");
assert!(c.is_index_file(&wal));
assert!(!c.is_index_file(&absolute.with_file_name("other.sqlite")));
}

View file

@ -396,8 +396,7 @@ mod tests {
&tx,
&NewFile {
name: f.file_name().unwrap().to_str().unwrap(),
path: f.to_str().unwrap(),
parent: d.to_str().unwrap(),
parent: &crate::file_handling::dir_to_db_parent(&d),
size: std::fs::metadata(&f).unwrap().len(),
mtime: 1,
mime: Some("text/plain"),
@ -494,7 +493,7 @@ mod tests {
.lock()
.unwrap()
.query_row(
"SELECT content_state FROM files WHERE path = ?1",
"SELECT content_state FROM files WHERE parent || name = ?1",
rusqlite::params![p.to_str().unwrap()],
|r| r.get(0),
)

View file

@ -234,6 +234,8 @@ impl IndexCoordinator {
last_event_at: None,
pending_since: None,
needs_full_run: false,
run_retry_at: None,
run_retry_delay: inner::RUN_RETRY_BASE,
pending_work: None,
reconcile_done: None,
reconcile_cut_short: false,

View file

@ -3,6 +3,32 @@
use super::*;
/// First wait after a full run is refused or fails to start.
pub(super) const RUN_RETRY_BASE: Duration = Duration::from_secs(30);
/// Ceiling for that wait. A misconfiguration that is never fixed then costs
/// one attempt every few minutes instead of one per second.
const RUN_RETRY_MAX: Duration = Duration::from_secs(300);
/// Individually-logged occurrences of each refusal before it goes quiet. The
/// message is identical every time, and the log ring is the only record of
/// what *else* happened; these are reset the moment a run starts.
static NO_ROOTS: crate::log::Throttle = crate::log::Throttle::new(3);
static NESTED_ROOTS: crate::log::Throttle = crate::log::Throttle::new(3);
static START_FAILURES: crate::log::Throttle = crate::log::Throttle::new(3);
/// Why a full run is being started, which is what decides how a refusal is
/// reported. See [`Inner::refuse`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RunTrigger {
/// Something the user did: *Index now*, *Rebuild index*, or a settings
/// change wide enough to need a walk. A refusal owes them an answer.
Requested,
/// The periodic timer, or repair the coordinator scheduled for itself. A
/// refusal here is worth one log line and then quiet.
Scheduled,
}
pub(super) struct Inner {
pub(super) config: Config,
pub(super) indexing: Arc<IndexingService>,
@ -40,6 +66,18 @@ pub(super) struct Inner {
/// defer application past `pending_max_defer`.
pub(super) pending_since: Option<Instant>,
pub(super) needs_full_run: bool,
/// Earliest time another full run may be *attempted*, when the last one
/// was refused or failed. Without it a config with no roots, or with
/// nested roots, or an index on a full disk, retries every tick forever:
/// two stderr writes and two ring pushes a second, which evicts the whole
/// 5000-line log ring in under an hour and takes the diagnostics for the
/// actual problem with it — and in the failed-run case re-walks the tree
/// each time.
pub(super) run_retry_at: Option<Instant>,
/// How long the next refusal waits, doubling to [`RUN_RETRY_MAX`] so a
/// permanent misconfiguration costs almost nothing, and reset once a run
/// actually starts.
pub(super) run_retry_delay: Duration,
/// Reconciliation owed to a config change, part-applied across ticks.
/// Unlike `needs_full_run`, this is acted on in manual mode too.
pub(super) pending_work: Option<WorkCursor>,
@ -95,7 +133,7 @@ impl Inner {
CoordCmd::SetMode(IndexMode::ManualStopped) => self.enter_manual_stopped(),
// ManualRunning isn't directly settable; ReindexNow is the verb.
CoordCmd::SetMode(IndexMode::ManualRunning) | CoordCmd::ReindexNow => {
self.start_full_run();
self.start_full_run(RunTrigger::Requested);
if self.mode != IndexMode::Auto {
self.mode = IndexMode::ManualRunning;
}
@ -139,10 +177,18 @@ impl Inner {
self.pending_work = None;
self.reconcile_done = None;
if let Err(e) = self.indexing.delete_index_for_rebuild(&db) {
// Not a warning to step over: a run started now would
// reopen the *old* index and present itself as the rebuild
// the user asked for. Worse after a password change, where
// the config already claims a protection the surviving
// file does not have. Report it and leave the index alone.
crate::log_warn!("coordinator: rebuild: {}", e);
self.indexing
.report_error(format!("could not rebuild the index: {}", e));
return;
}
self.clear_root_counts();
self.start_full_run();
self.start_full_run(RunTrigger::Requested);
if self.mode != IndexMode::Auto {
self.mode = IndexMode::ManualRunning;
}
@ -174,7 +220,7 @@ impl Inner {
// delivers anything else, so nothing downstream checks, and
// a file renamed *out* of every root would otherwise be
// written into the index at its new home. Roots in the same
// spelling `files.path` uses — the caller's paths are.
// spelling the index uses — the caller's paths are.
let prefixes: Vec<String> = self
.config
.normalized_indexing_paths()
@ -224,6 +270,12 @@ impl Inner {
if self.saw_running {
self.saw_running = false;
self.was_busy = true;
// A run that errored never stamps `last_full_index`, so
// `periodic_due` stays true and the next tick starts another one —
// a fresh whole-tree walk per second against, say, a full disk.
if matches!(status, IndexingStatus::Error(_)) {
self.defer_runs();
}
self.refresh_last_full_index();
// Eager re-read: the run just changed the number on screen.
self.files_at = None;
@ -263,8 +315,9 @@ impl Inner {
worked = true;
}
if self.needs_full_run || self.periodic_due() {
self.start_full_run();
let deferred = self.run_retry_at.is_some_and(|at| Instant::now() < at);
if (self.needs_full_run || self.periodic_due()) && !deferred {
self.start_full_run(RunTrigger::Scheduled);
worked = true;
}
@ -408,7 +461,7 @@ impl Inner {
// Widening adds files only a walk can produce; mirrors `ReindexNow`,
// including the manual-mode round trip back to stopped.
if cursor.reindex() {
self.start_full_run();
self.start_full_run(RunTrigger::Requested);
if self.mode != IndexMode::Auto {
self.mode = IndexMode::ManualRunning;
}
@ -555,6 +608,9 @@ impl Inner {
Err(e) => {
crate::log_warn!("coordinator: targeted update unavailable: {}", e);
self.targeted.clear();
// As in `start_full_run`: a resume point outlives its event
// here too, and would misapply to the next one for that path.
self.resume_from.retain(|p, _| self.pending.contains_key(p));
return;
}
};
@ -633,10 +689,15 @@ impl Inner {
}
fn periodic_due(&self) -> bool {
// `.max(1)`: at zero every tick is "due", so a full run would start
// the moment the previous one finished, forever. There is no setting
// that means "reindex continuously" — manual mode is how you say
// "never", so zero is a typo rather than an intent.
let interval_secs = self
.config
.indexing
.reindex_interval_minutes
.max(1)
.saturating_mul(60);
let last = crate::lock_ok(&self.shared).last_full_index;
match last {
@ -651,7 +712,20 @@ impl Inner {
}
}
fn start_full_run(&mut self) {
fn start_full_run(&mut self, trigger: RunTrigger) {
// A run already in progress is not a refusal: whoever called this
// wanted a run and there is one. Returning here rather than falling
// through to `start_indexing`'s "already running" error keeps an
// *Index now* pressed mid-run from backing off future runs and from
// replacing the progress the user is watching with an error. It also
// covers `Optimizing`, which `start_indexing` does not reject even
// though it holds a write transaction over the whole file.
if !matches!(
self.indexing.get_status(),
IndexingStatus::Idle | IndexingStatus::Error(_)
) {
return;
}
let roots: Vec<String> = self
.config
.resolved_indexing_paths()
@ -659,20 +733,23 @@ impl Inner {
.map(|p| p.to_string_lossy().into_owned())
.collect();
if roots.is_empty() {
crate::log_warn!("coordinator: no indexing roots configured");
self.refuse(trigger, &NO_ROOTS, "no indexing roots are configured");
return;
}
// Backstop for hand-edited configs; the GUI rejects nested roots
// itself.
let nested = crate::config::nested_roots(&roots);
if !nested.is_empty() {
for (child, parent) in &nested {
crate::log_warn!(
"coordinator: refusing to index: root {} is nested under {}",
child,
parent
let detail = nested
.iter()
.map(|(child, parent)| format!("{} is nested under {}", child, parent))
.collect::<Vec<_>>()
.join("; ");
self.refuse(
trigger,
&NESTED_ROOTS,
&format!("refusing to index nested roots: {}", detail),
);
}
return;
}
// The full run owns the DB (and may wipe/rebuild the file).
@ -683,6 +760,12 @@ impl Inner {
// unreadable directory (`unreadable.covers`) or of an aliased symlink
// target (`aliased_paths`), and those rows would leak until a rebuild.
self.pending.retain(|_, ev| is_removal(ev));
// Resume points describe events that no longer exist. Left behind, the
// next `Create` for that path would `skip` the first n entries of a
// walk that has nothing to do with the one the count came from, and
// those files would have no row until a later full run.
self.resume_from
.retain(|p, _| self.pending.contains_key(p) || self.targeted.contains_key(p));
if self.pending.is_empty() {
self.last_event_at = None;
self.pending_since = None;
@ -691,15 +774,58 @@ impl Inner {
.indexing
.start_indexing(roots, self.db_path(), self.config.clone())
{
crate::log_warn!("coordinator: start indexing: {}", e);
self.refuse(
trigger,
&START_FAILURES,
&format!("could not start indexing: {}", e),
);
return;
}
// A run started, so whatever was wrong is not wrong any more.
self.run_retry_at = None;
self.run_retry_delay = RUN_RETRY_BASE;
NO_ROOTS.reset();
NESTED_ROOTS.reset();
START_FAILURES.reset();
// `start_indexing` claims Running before returning, so there is no
// window in which this thread believes the service idle and writes to
// a database the run is about to reopen.
self.saw_running = true;
}
/// Refuse a full run: back off, and say so at a volume the trigger earns.
///
/// The throttles exist for the *scheduled* retry, which repeats the same
/// sentence every few minutes for as long as the configuration stays
/// broken and would otherwise evict the log ring. They must not silence a
/// run somebody asked for: after three refusals a fourth press of *Index
/// now* would log nothing, show nothing and change nothing, which reads as
/// a button that does not work. So a requested run resets the throttle —
/// it is a fresh occurrence in the user's eyes — and publishes the reason
/// where they are already looking, the same way a failed rebuild does.
fn refuse(&mut self, trigger: RunTrigger, throttle: &crate::log::Throttle, reason: &str) {
self.defer_runs();
if trigger == RunTrigger::Requested {
throttle.reset();
self.indexing.report_error(reason.to_string());
}
if throttle.allow() {
crate::log_warn!("coordinator: {}", reason);
}
}
/// Hold off further full runs for a while, doubling the wait each time.
///
/// Called wherever a run is refused or fails to start. `needs_full_run` is
/// cleared with it: the two refusal paths sit above the point that clears
/// it, so without this the flag stays set and `tick` retries immediately,
/// every second, for as long as the configuration stays broken.
fn defer_runs(&mut self) {
self.needs_full_run = false;
self.run_retry_at = Some(Instant::now() + self.run_retry_delay);
self.run_retry_delay = (self.run_retry_delay * 2).min(RUN_RETRY_MAX);
}
fn enter_auto(&mut self) {
self.mode = IndexMode::Auto;
self.config.indexing.auto_index = true;
@ -811,6 +937,29 @@ impl Inner {
let Some(mut reason) = w.degraded_reason() else {
return;
};
// An overflow is not a capacity problem: the watcher is still good,
// it just cannot tell us what it missed. Schedule the run that finds
// out, rather than disabling live updates for the session over
// something a busy minute can cause.
//
// Not a restart. `inotify` and `ReadDirectoryChangesW` both keep
// delivering after their queue overflows — the rescan flag means "you
// missed some", not "this watch is broken" — so tearing down and
// re-registering would re-`stat` every one of `max_watched_dirs`
// (128k) directories, generate events of its own, and do it again the
// next time a busy tree overflowed the queue, which is exactly when
// the machine can least afford it.
//
// Consuming the reason is what makes this a one-shot: the slot is
// never otherwise cleared, so a standing `Overflowed` would re-arm
// `needs_full_run` on every tick and hide a `KernelLimit` recorded
// afterwards.
if matches!(reason, WatchError::Overflowed) {
w.clear_degraded();
crate::log_warn!("watcher: {}", reason);
self.needs_full_run = true;
return;
}
// The async notify callback can't know the count; fill it in here.
if let WatchError::KernelLimit { registered } = &mut reason {
if *registered == 0 {

View file

@ -512,7 +512,7 @@ impl Fixture {
fn stored_mtime(&self, path: &std::path::Path) -> Option<i64> {
let conn = db::open_existing(&self.db.to_string_lossy(), false).ok()?;
conn.query_row(
"SELECT mtime FROM files WHERE path = ?1",
"SELECT mtime FROM files WHERE parent || name = ?1",
[path.to_string_lossy().as_ref()],
|r| r.get(0),
)

View file

@ -17,8 +17,8 @@ pub mod schema;
pub use key::{process_key_hex, set_process_key};
pub use open::{
index_needs_rebuild, open_existing, open_or_recreate, verify_process_key,
CURRENT_SCHEMA_VERSION, KEY_MISMATCH_PREFIX,
index_needs_rebuild, key_mismatch_parts, open_existing, open_or_recreate, verify_process_key,
KeyMismatch, CURRENT_SCHEMA_VERSION, KEY_MISMATCH_PREFIX,
};
/// Bumped whenever the index file is replaced rather than modified — a

View file

@ -25,7 +25,7 @@ pub const KEY_MISMATCH_PREFIX: &str = "KEY_MISMATCH: ";
/// values go stale: `files.mime`, `files.type` and `content_state` are
/// computed at walk time and never re-derived for unchanged files, so a
/// classification change needs the wipe to apply everywhere.
pub const CURRENT_SCHEMA_VERSION: u32 = 7;
pub const CURRENT_SCHEMA_VERSION: u32 = 8;
/// Open `db_path` and ensure the on-disk schema matches this build; if it
/// doesn't (including a changed `tokenizer`), delete the file and recreate it
@ -241,6 +241,57 @@ fn is_notadb(e: &rusqlite::Error) -> bool {
)
}
/// Why a keyed open failed, as something the caller can branch on.
///
/// The three cases want three different things from a user — retype the
/// password, rebuild the index, supply a password at all — and only one of
/// them is "wrong password". They used to be distinguishable only by reading
/// the English in the message, which breaks the moment a database path
/// happens to contain that English.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyMismatch {
/// A key was applied and the file did not accept it.
WrongPassword,
/// A key was applied but the file on disk is not encrypted at all —
/// protection was enabled and the rebuild that would encrypt it did not
/// finish.
NotEncrypted,
/// No key was applied and the file wants one.
PasswordRequired,
}
impl KeyMismatch {
/// The machine-readable token carried in the message, between
/// [`KEY_MISMATCH_PREFIX`] and the human detail.
fn token(self) -> &'static str {
match self {
KeyMismatch::WrongPassword => "wrong-password",
KeyMismatch::NotEncrypted => "not-encrypted",
KeyMismatch::PasswordRequired => "password-required",
}
}
fn from_token(token: &str) -> Option<KeyMismatch> {
match token {
"wrong-password" => Some(KeyMismatch::WrongPassword),
"not-encrypted" => Some(KeyMismatch::NotEncrypted),
"password-required" => Some(KeyMismatch::PasswordRequired),
_ => None,
}
}
}
/// Split a tagged mismatch message into its cause and the human detail.
///
/// `None` for any message that is not one — including a `KEY_MISMATCH_PREFIX`
/// message from an older build, which callers should treat as they always did.
pub fn key_mismatch_parts(message: &str) -> Option<(KeyMismatch, &str)> {
let rest = message.strip_prefix(KEY_MISMATCH_PREFIX)?;
let (token, detail) = rest.split_once(' ')?;
let token = token.strip_suffix(':')?;
Some((KeyMismatch::from_token(token)?, detail))
}
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
@ -255,15 +306,31 @@ fn key_mismatch_message(db_path: &str, had_key: bool) -> String {
Some(&magic == b"SQLite format 3\0")
})
.unwrap_or(false);
let detail = match (had_key, plaintext) {
(true, true) => {
let (cause, detail) = match (had_key, plaintext) {
(true, true) => (
KeyMismatch::NotEncrypted,
"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",
rebuild the index to encrypt it",
),
(true, false) => (
KeyMismatch::WrongPassword,
"wrong password (or the file is not a QuickSearch index)",
),
(false, _) => (
KeyMismatch::PasswordRequired,
"the index is password-protected; a password is required",
),
};
format!("{}index at {}: {}", KEY_MISMATCH_PREFIX, db_path, detail)
// The token sits between the prefix and the detail so that every existing
// `starts_with(KEY_MISMATCH_PREFIX)` test still holds, while a caller that
// needs the cause can have it without reading prose.
format!(
"{}{}: index at {}: {}",
KEY_MISMATCH_PREFIX,
cause.token(),
db_path,
detail
)
}
/// True iff the DB has a `schema_info` table whose `version` equals

View file

@ -124,8 +124,8 @@ fn tokenizer_drift_wipes_db() {
let first_effective = {
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
conn.execute(
"INSERT INTO files (name, path, parent, size, mtime) \
VALUES ('x', '/x', '/', 0, 0)",
"INSERT INTO files (name, parent, size, mtime) \
VALUES ('x', '/', 0, 0)",
[],
)
.unwrap();
@ -164,8 +164,8 @@ fn open_existing_reads_nondefault_tokenizer_without_wiping() {
{
let conn = open_or_recreate(p.to_str().unwrap(), "unicode61").unwrap();
conn.execute(
"INSERT INTO files (name, path, parent, size, mtime) \
VALUES ('note', '/note.txt', '/', 0, 0)",
"INSERT INTO files (name, parent, size, mtime) \
VALUES ('note', '/', 0, 0)",
[],
)
.unwrap();
@ -384,8 +384,8 @@ fn keyed_create_reopen_and_header_is_encrypted() {
{
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)",
"INSERT INTO files (name, parent, size, mtime) \
VALUES ('secret', '/', 0, 0)",
[],
)
.unwrap();
@ -418,8 +418,8 @@ fn wrong_key_errors_without_wiping() {
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)",
"INSERT INTO files (name, parent, size, mtime) \
VALUES ('x', '/', 0, 0)",
[],
)
.unwrap();
@ -532,15 +532,15 @@ fn open_existing_rw_allows_delete() {
{
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
conn.execute(
"INSERT INTO files (name, path, parent, size, mtime) \
VALUES ('a', '/a', '/', 0, 0)",
"INSERT INTO files (name, parent, size, mtime) \
VALUES ('a', '/', 0, 0)",
[],
)
.unwrap();
}
let conn = open_existing(p.to_str().unwrap(), true).unwrap();
let removed = conn
.execute("DELETE FROM files WHERE path = '/a'", [])
.execute("DELETE FROM files WHERE parent = '/' AND name = 'a'", [])
.unwrap();
assert_eq!(removed, 1);
drop(conn);
@ -571,8 +571,8 @@ fn a_fresh_index_and_its_sidecars_are_owner_only() {
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
// A write, so the WAL and SHM exist to be checked.
conn.execute(
"INSERT INTO files (name, path, parent, size, mtime) \
VALUES ('a', '/perm-a', '/', 0, 0)",
"INSERT INTO files (name, parent, size, mtime) \
VALUES ('a', '/', 0, 0)",
[],
)
.unwrap();
@ -598,3 +598,47 @@ fn a_fresh_index_and_its_sidecars_are_owner_only() {
drop(conn);
std::fs::remove_file(&p).ok();
}
/// `maintain` must work on a *keyed* index, which is the one case the pragma
/// it reads does not answer with an integer.
///
/// SQLCipher intercepts `PRAGMA page_size` on a keyed connection, answers with
/// `cipher_page_size`, and hands that back as TEXT. Reading it straight into an
/// `i64` failed there and only there — so every unencrypted test passed while
/// every index with a password set silently skipped both its VACUUM and its
/// `PRAGMA optimize`. The assertion is simply that the call succeeds: it has to
/// get past all three pragma reads to return at all.
#[test]
fn maintain_reads_its_pragmas_on_a_keyed_index() {
let p = tmp_db_path();
let key = test_key(0xc3);
let dir = p.parent().unwrap().to_string_lossy().into_owned();
{
let conn = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&key)).unwrap();
conn.execute(
"INSERT INTO files (name, parent, size, mtime) VALUES ('x', '/', 0, 0)",
[],
)
.unwrap();
}
let conn = open_keyed_with_pragmas(p.to_str().unwrap(), true, Some(&key), PRAGMAS_MAINTENANCE)
.unwrap();
// A two-row index has no slack worth reclaiming, so `false` is the
// expected answer — what matters is that it is an answer and not an error.
assert_eq!(
crate::db::repo::maintain(&conn, &dir),
Ok(false),
"maintain must not fail on a keyed index"
);
// And the value itself has to be usable, not merely readable: a page size
// that parsed as 0 would size the free-space check at zero bytes and wave
// through a VACUUM that cannot fit.
assert!(
crate::db::repo::pragma_number(&conn, "page_size").unwrap() >= 512,
"a real page size, not a silent zero"
);
drop(conn);
std::fs::remove_file(&p).ok();
}

View file

@ -62,7 +62,8 @@ fn set_state_clearing_failure(
#[derive(Debug, Clone)]
pub struct NewFile<'a> {
pub name: &'a str,
pub path: &'a str,
/// The containing directory, ending in the platform separator — see
/// [`crate::file_handling::split_db_path`], which produces the pair.
pub parent: &'a str,
pub size: u64,
pub mtime: u64,
@ -74,21 +75,29 @@ pub struct NewFile<'a> {
pub needs_content: bool,
}
impl NewFile<'_> {
/// The file's path, for a log or error message. Not stored; see
/// [`super::schema::SCHEMA_CURRENT`].
fn path(&self) -> String {
format!("{}{}", self.parent, self.name)
}
}
/// Insert a new file row, returning its id. `content_state` comes from
/// `needs_content`; there is no separate basic state, because the row
/// existing *is* the basic-index state. `INSERT OR IGNORE`: a UNIQUE(path)
/// collision returns `None` rather than aborting the batch.
/// existing *is* the basic-index state. `INSERT OR IGNORE`: a
/// `UNIQUE(parent, name)` collision returns `None` rather than aborting the
/// batch.
pub fn insert_file(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
let rows = tx
.prepare_cached(
"INSERT OR IGNORE INTO files (
name, path, parent, size, mtime, mime, type, content_state, hash
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
name, parent, size, mtime, mime, type, content_state, hash
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)
.and_then(|mut stmt| {
stmt.execute(params![
f.name,
f.path,
f.parent,
f.size as i64,
f.mtime as i64,
@ -98,7 +107,7 @@ pub fn insert_file(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>,
f.hash,
])
})
.map_err(|e| format!("insert file {}: {}", f.path, e))?;
.map_err(|e| format!("insert file {}: {}", f.path(), e))?;
if rows == 0 {
return Ok(None);
}
@ -120,14 +129,14 @@ fn initial_content_state(f: &NewFile<'_>) -> i64 {
/// reset its content state from `f.needs_content`, clearing any extracted
/// content so the text-indexing pass re-processes it. Writes `size`, `mtime`,
/// `hash`, `mime`, `type` and `content_state` — and only those; `name` and
/// `parent` are not refreshed here.
/// `parent` are the key it matches on, so they cannot change here.
pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
let id: Option<i64> = tx
.prepare_cached(
"UPDATE files
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5,
content_state = ?6
WHERE path = ?7
WHERE parent = ?7 AND name = ?8
RETURNING id",
)
.and_then(|mut stmt| {
@ -139,13 +148,14 @@ pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option
f.mime,
f.ftype.bits() as i64,
initial_content_state(f),
f.path,
f.parent,
f.name,
],
|r| r.get(0),
)
.optional()
})
.map_err(|e| format!("update file {}: {}", f.path, e))?;
.map_err(|e| format!("update file {}: {}", f.path(), e))?;
let Some(id) = id else {
return Ok(None);
};
@ -365,29 +375,38 @@ pub fn set_content_na(tx: &Transaction<'_>, file_id: i64) -> Result<(), String>
}
/// Delete a file row by path, keeping FTS in sync. Returns whether a row was
/// removed.
/// removed — including `false` for a string that cannot be a file's path at
/// all, which is not in the index by construction.
pub fn delete_file_by_path(tx: &Transaction<'_>, path: &str) -> Result<bool, String> {
let Some((parent, name)) = crate::file_handling::split_db_path(path) else {
return Ok(false);
};
let id: Option<i64> = tx
.prepare_cached("DELETE FROM files WHERE path = ?1 RETURNING id")
.and_then(|mut stmt| stmt.query_row(params![path], |r| r.get(0)).optional())
.prepare_cached("DELETE FROM files WHERE parent = ?1 AND name = ?2 RETURNING id")
.and_then(|mut stmt| {
stmt.query_row(params![parent, name], |r| r.get(0))
.optional()
})
.map_err(|e| format!("delete file {}: {}", path, e))?;
let Some(id) = id else { return Ok(false) };
remove_content_for_id(tx, id)?;
Ok(true)
}
/// Delete every row whose path falls in the half-open range `[lo, hi)`,
/// Delete every row whose parent falls in the half-open range `[lo, hi)`,
/// keeping the dependent tables in step. Returns how many `files` rows went.
///
/// Five statements regardless of how many files the range holds, and the
/// range is an index seek on `UNIQUE(files.path)`. Build the bounds with
/// [`crate::file_handling::ExtractCursor::for_root`], which is what makes
/// them separator-correct.
/// Four statements regardless of how many files the range holds, and the
/// range is an index seek on `UNIQUE(files.parent, files.name)`. Build the
/// bounds with [`crate::file_handling::ExtractCursor::for_root`], which is
/// what makes them separator-correct — and note the range covers the root's
/// *own* files only because every stored parent ends in a separator (see
/// `dir_to_db_parent`).
pub fn delete_subtree(tx: &Transaction<'_>, lo: &str, hi: &str) -> Result<usize, String> {
for (table, key) in DEPENDENT_TABLES {
let sql = format!(
"DELETE FROM {} WHERE {} IN \
(SELECT id FROM files WHERE path >= ?1 AND path < ?2)",
(SELECT id FROM files WHERE parent >= ?1 AND parent < ?2)",
table, key
);
exec(tx, &sql, params![lo, hi], || {
@ -396,13 +415,13 @@ pub fn delete_subtree(tx: &Transaction<'_>, lo: &str, hi: &str) -> Result<usize,
}
exec(
tx,
"DELETE FROM files WHERE path >= ?1 AND path < ?2",
"DELETE FROM files WHERE parent >= ?1 AND parent < ?2",
params![lo, hi],
|| format!("delete files under {}", lo),
)
}
/// Delete every row whose path falls in *none* of `ranges`. Returns how many
/// Delete every row whose parent falls in *none* of `ranges`. Returns how many
/// `files` rows went.
///
/// A scan of `files` rather than a seek, reserved for the one transition that
@ -423,7 +442,7 @@ pub fn delete_outside_ranges(
predicate.push_str(" AND ");
}
predicate.push_str(&format!(
"NOT (path >= ?{} AND path < ?{})",
"NOT (parent >= ?{} AND parent < ?{})",
i * 2 + 1,
i * 2 + 2
));
@ -485,8 +504,12 @@ pub fn delete_ids(tx: &Transaction<'_>, ids: &[i64]) -> Result<usize, String> {
Ok(removed)
}
/// Every indexed file directly inside `parent`, as `name -> mtime`. Served by
/// `idx_files_parent`: one index range lookup.
/// Every indexed file directly inside `parent`, as `name -> mtime`.
///
/// `parent` must be in stored spelling — trailing separator and all; build it
/// with [`crate::file_handling::dir_to_db_parent`]. One `idx_files_parent`
/// range lookup for the names, then a row fetch each for the mtimes; see the
/// index's own comment for why that is the shape it is.
pub fn dir_rows(
conn: &Connection,
parent: &str,
@ -507,7 +530,9 @@ pub fn dir_rows(
Ok(out)
}
/// A row the content pass has yet to extract: `(id, name, path, mime)`.
/// A row the content pass has yet to extract: `(id, name, path, mime)`. The
/// path is reassembled here rather than stored — see
/// [`super::schema::SCHEMA_CURRENT`] — because the pass opens the file by it.
pub type PendingContentRow = (i64, String, String, Option<String>);
/// One page of rows still awaiting content extraction under `cursor`'s range,
@ -526,11 +551,11 @@ pub fn pending_content_page(
.prepare_cached(
// `INDEXED BY` rather than a hint, because the planner gets this
// one wrong exactly when it costs most. Left to itself it takes
// `UNIQUE(path)` for the range and then sorts the survivors into a
// temp b-tree to satisfy `ORDER BY id` — which means every page
// walks the whole root's range and fetches each row's heap entry
// to test `content_state`. At `FEED_PAGE` rows per page that is
// quadratic over a run. The partial index below is already
// `idx_files_parent` for the range and then sorts the survivors
// into a temp b-tree to satisfy `ORDER BY id` — which means every
// page walks the whole root's range and fetches each row's heap
// entry to test `content_state`. At `FEED_PAGE` rows per page that
// is quadratic over a run. The partial index below is already
// id-ordered, so it answers `id > ?` and the ORDER BY together and
// holds only pending rows. Measured on 500k rows with everything
// pending — the first index of a tree, i.e. the case that matters:
@ -539,9 +564,9 @@ pub fn pending_content_page(
// The planner only prefers it once pending rows are a small
// minority, and never before ANALYZE has run at all, which is why
// this cannot be left to statistics.
"SELECT id, name, path, mime FROM files INDEXED BY idx_files_content_pending
"SELECT id, parent, name, mime FROM files INDEXED BY idx_files_content_pending
WHERE content_state = 0 AND size <= ?1 AND id > ?2
AND path >= ?3 AND path < ?4
AND parent >= ?3 AND parent < ?4
ORDER BY id
LIMIT ?5",
)
@ -550,10 +575,13 @@ pub fn pending_content_page(
.query_map(
params![max_size, cursor.last_id, cursor.lo, cursor.hi, limit],
|row| {
let parent: String = row.get(1)?;
let name: String = row.get(2)?;
let path = format!("{}{}", parent, name);
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
name,
path,
row.get::<_, Option<String>>(3)?,
))
},
@ -565,10 +593,16 @@ pub fn pending_content_page(
/// A stored row as the scope reconciler sees it: enough to decide both
/// whether the path is still in scope and whether its content still is.
///
/// `path` is `parent` and `name` joined, kept alongside them because the
/// reconciler tests it as a [`std::path::Path`] while the cursor resumes from
/// the key.
#[derive(Debug, Clone)]
pub struct ScopeRow {
pub id: i64,
pub path: String,
pub parent: String,
pub name: String,
pub size: u64,
pub mime: Option<String>,
pub content_state: i64,
@ -590,7 +624,7 @@ pub struct RootCounts {
pub fts: i64,
}
/// Count the rows in the half-open path range `[lo, hi)` and, in the same
/// Count the rows in the half-open parent range `[lo, hi)` and, in the same
/// pass, how many of them have a full-text row.
///
/// `content_state = STATE_DONE` *is* "has a `searchabletext` row":
@ -600,13 +634,13 @@ pub struct RootCounts {
/// contentless and keyed by `rowid`, so it has no path to range-scan on.
///
/// One statement, but not a cheap one: `content_state` is not carried by the
/// `UNIQUE(files.path)` index the range seeks on, so every row in the range is
/// `idx_files_parent` index the range seeks on, so every row in the range is
/// fetched. Call it where a run has just read those rows anyway, not on a
/// cadence.
pub fn count_root(conn: &Connection, lo: &str, hi: &str) -> Result<RootCounts, String> {
conn.prepare_cached(
"SELECT COUNT(*), COALESCE(SUM(content_state = ?3), 0) FROM files
WHERE path >= ?1 AND path < ?2",
WHERE parent >= ?1 AND parent < ?2",
)
.and_then(|mut stmt| {
stmt.query_row(params![lo, hi, STATE_DONE], |r| {
@ -619,37 +653,46 @@ pub fn count_root(conn: &Connection, lo: &str, hi: &str) -> Result<RootCounts, S
.map_err(|e| format!("count root {}: {}", lo, e))
}
/// One page of rows whose path is `> after` and `< hi`, in path order.
/// One page of rows sorting after `(after_parent, after_name)` and inside the
/// parent range ending at `hi`, in `(parent, name)` order.
///
/// Keyset on `path`: every page is an index walk with no sort step, and a row
/// is served at most once even though the caller is deleting behind the
/// reader. Seed `after` with the range's `lo` bound, which is
/// `root + separator` and so can never equal a stored path.
/// Keyset on the `idx_files_parent` key itself: every page is an index walk
/// with no sort step, and a row is served at most once even though the caller
/// is deleting behind the reader. Seed the cursor with `(lo, "")` — no name is
/// empty, so that lands exactly on the first row of the range.
///
/// The row-value comparison is what keeps it one seek; spelled out as
/// `parent > ? OR (parent = ? AND name > ?)` the planner is free to scan.
pub fn rows_in_range_page(
conn: &Connection,
after: &str,
after_parent: &str,
after_name: &str,
hi: &str,
limit: i64,
) -> Result<Vec<ScopeRow>, String> {
let mut stmt = conn
.prepare_cached(
"SELECT id, path, size, mime, content_state FROM files
WHERE path > ?1 AND path < ?2
ORDER BY path
LIMIT ?3",
"SELECT id, parent, name, size, mime, content_state FROM files
WHERE (parent, name) > (?1, ?2) AND parent < ?3
ORDER BY parent, name
LIMIT ?4",
)
.map_err(|e| format!("prepare range page: {}", e))?;
let rows = stmt
.query_map(params![after, hi, limit], |row| {
.query_map(params![after_parent, after_name, hi, limit], |row| {
let parent: String = row.get(1)?;
let name: String = row.get(2)?;
Ok(ScopeRow {
id: row.get(0)?,
path: row.get(1)?,
size: row.get::<_, i64>(2)?.max(0) as u64,
mime: row.get(3)?,
content_state: row.get(4)?,
path: format!("{}{}", parent, name),
name,
parent,
size: row.get::<_, i64>(3)?.max(0) as u64,
mime: row.get(4)?,
content_state: row.get(5)?,
})
})
.map_err(|e| format!("query range page after {}: {}", after, e))?;
.map_err(|e| format!("query range page after {}: {}", after_parent, e))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("read range page row: {}", e))
}
@ -683,18 +726,26 @@ pub fn reset_content_pending(tx: &Transaction<'_>, file_id: i64) -> Result<(), S
/// files whose parent isn't the directory being read (a resolved symlink
/// target), where [`dir_rows`] would not have them.
pub fn mtime_for_path(conn: &Connection, path: &str) -> Result<Option<u64>, String> {
let Some((parent, name)) = crate::file_handling::split_db_path(path) else {
return Ok(None);
};
let mut stmt = conn
.prepare_cached("SELECT mtime FROM files WHERE path = ?1")
.prepare_cached("SELECT mtime FROM files WHERE parent = ?1 AND name = ?2")
.map_err(|e| format!("prepare mtime lookup for {}: {}", path, e))?;
stmt.query_row(params![path], |r| r.get::<_, i64>(0))
stmt.query_row(params![parent, name], |r| r.get::<_, i64>(0))
.optional()
.map(|o| o.map(|m| m.max(0) as u64))
.map_err(|e| format!("mtime lookup for {}: {}", path, e))
}
/// Distinct `parent` values within the half-open path range `[lo, hi)`,
/// streamed to `f` so nothing proportional to the tree is materialized.
/// `idx_files_parent` makes this an index-only scan.
/// Distinct `parent` values within the half-open range `[lo, hi)`, streamed to
/// `f` so nothing proportional to the tree is materialized. `idx_files_parent`
/// makes this an index-only scan.
///
/// The root's own directory is included: its stored parent is `root + SEP`,
/// which is exactly `lo`. It was not, back when the same bounds were applied
/// to a `path` column and the root's parent was spelled without the trailing
/// separator.
pub fn for_each_parent_in_range<F: FnMut(String)>(
conn: &Connection,
lo: &str,
@ -713,13 +764,17 @@ pub fn for_each_parent_in_range<F: FnMut(String)>(
Ok(())
}
/// Paths of every file directly inside `parent`.
/// Paths of every file directly inside `parent`, which must carry its trailing
/// separator — so the join below is a concatenation.
pub fn paths_in_dir(conn: &Connection, parent: &str) -> Result<Vec<String>, String> {
let mut stmt = conn
.prepare_cached("SELECT path FROM files WHERE parent = ?1")
.prepare_cached("SELECT name FROM files WHERE parent = ?1")
.map_err(|e| format!("prepare paths in {}: {}", parent, e))?;
let rows = stmt
.query_map(params![parent], |r| r.get::<_, String>(0))
.query_map(params![parent], |r| {
r.get::<_, String>(0)
.map(|name| format!("{}{}", parent, name))
})
.map_err(|e| format!("query paths in {}: {}", parent, e))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("read path under {}: {}", parent, e))
@ -795,6 +850,33 @@ pub fn checkpoint_and_close(conn: Connection) {
drop(conn);
}
/// Read a `PRAGMA` that reports a number.
///
/// Not simply `r.get::<i64>(0)`, because SQLCipher does not always answer with
/// one. On a **keyed** connection it intercepts `PRAGMA page_size`, answers
/// with `cipher_page_size` instead, and returns that as TEXT — so asking for an
/// integer fails with a type error. Unencrypted it is an INTEGER as usual,
/// which is why this only ever broke protected installs, and only in
/// [`maintain`]: every index with a password set skipped its VACUUM *and* its
/// `PRAGMA optimize` from the moment the free-space check was added.
///
/// A value that is neither is an error rather than a guess — the callers here
/// size a disk-space check with it.
pub(super) fn pragma_number(conn: &Connection, pragma: &str) -> Result<i64, String> {
use rusqlite::types::ValueRef;
conn.query_row(&format!("PRAGMA {}", pragma), [], |r| {
Ok(match r.get_ref(0)? {
ValueRef::Integer(n) => Some(n),
ValueRef::Text(t) => std::str::from_utf8(t)
.ok()
.and_then(|s| s.trim().parse().ok()),
_ => None,
})
})
.map_err(|e| format!("read {}: {}", pragma, e))?
.ok_or_else(|| format!("read {}: not a number", pragma))
}
/// Land the log, reclaim the file's slack, and refresh the query planner's
/// statistics. Returns whether it vacuumed.
///
@ -822,14 +904,33 @@ pub fn maintain(conn: &Connection, db_dir: &str) -> Result<bool, String> {
crate::log_warn!("{}", e);
}
let page_count: i64 = conn
.query_row("PRAGMA page_count", [], |r| r.get(0))
.map_err(|e| format!("read page_count: {}", e))?;
let freelist: i64 = conn
.query_row("PRAGMA freelist_count", [], |r| r.get(0))
.map_err(|e| format!("read freelist_count: {}", e))?;
let page_count = pragma_number(conn, "page_count")?;
let freelist = pragma_number(conn, "freelist_count")?;
let vacuumed = freelist * 100 >= page_count * VACUUM_MIN_SLACK_PERCENT;
let worth_it = freelist * 100 >= page_count * VACUUM_MIN_SLACK_PERCENT;
// The doc comment above puts VACUUM's peak transient need at roughly three
// times the index. Checking first turns "the volume filled up mid-rebuild"
// into a skipped compaction: a rollback is the *good* outcome there, and
// the bad one is that writes to the `-shm` mmap on a full filesystem come
// back as SIGBUS rather than as an error — see
// `indexing::pipeline::DISK_FLOOR`. Unknown free space is not a reason to
// skip.
let page_size = pragma_number(conn, "page_size")?;
let needed = (page_count.max(0) as u64).saturating_mul(page_size.max(0) as u64) * 3;
let room = match crate::platform::available_space(std::path::Path::new(db_dir)) {
Some(free) if free < needed => {
crate::log_warn!(
"skipping VACUUM: it needs about {} MiB free in {} and there is {} MiB",
needed / (1024 * 1024),
db_dir,
free / (1024 * 1024)
);
false
}
_ => true,
};
let vacuumed = worth_it && room;
if vacuumed {
// `temp_store_directory` is a deprecated pragma that writes a global,
// so it is set for the VACUUM and cleared straight after rather than

View file

@ -47,8 +47,7 @@ fn insert_update_delete_round_trip() {
&tx,
&NewFile {
name: "a.txt",
path: "/tmp/a.txt",
parent: "/tmp",
parent: "/tmp/",
size: 42,
mtime: 1_700_000_000,
mime: Some("text/plain"),
@ -101,8 +100,7 @@ fn insert_writes_content_state_from_needs_content() {
let tx = conn.transaction().unwrap();
let mut row = NewFile {
name: "claimed.txt",
path: "/tmp/claimed.txt",
parent: "/tmp",
parent: "/tmp/",
size: 1,
mtime: 1,
mime: Some("text/plain"),
@ -112,7 +110,6 @@ fn insert_writes_content_state_from_needs_content() {
};
let claimed = insert_file(&tx, &row).unwrap().expect("unique path");
row.name = "unclaimed.mp4";
row.path = "/tmp/unclaimed.mp4";
row.mime = Some("video/mp4");
row.needs_content = false;
let unclaimed = insert_file(&tx, &row).unwrap().expect("unique path");
@ -139,8 +136,7 @@ fn update_writes_content_state_from_needs_content() {
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let mut row = NewFile {
name: "a.txt",
path: "/tmp/a.txt",
parent: "/tmp",
parent: "/tmp/",
size: 10,
mtime: 1,
mime: None,
@ -216,8 +212,7 @@ fn insert_file_twice_on_same_path_is_idempotent() {
let tx = conn.transaction().unwrap();
let row = NewFile {
name: "dup.txt",
path: "/tmp/dup.txt",
parent: "/tmp",
parent: "/tmp/",
size: 1,
mtime: 1,
mime: Some("text/plain"),
@ -234,8 +229,8 @@ fn insert_file_twice_on_same_path_is_idempotent() {
assert_eq!(count, 1);
let (id_read,): (i64,) = tx
.query_row(
"SELECT id FROM files WHERE path = ?1",
params!["/tmp/dup.txt"],
"SELECT id FROM files WHERE parent = ?1 AND name = ?2",
params!["/tmp/", "dup.txt"],
|r| Ok((r.get(0)?,)),
)
.unwrap();
@ -250,13 +245,11 @@ fn delete_subtree_clears_every_dependent_table() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let add = |tx: &Transaction<'_>, path: &str| -> i64 {
let name = path.rsplit('/').next().unwrap();
let parent = &path[..path.rfind('/').unwrap()];
let (parent, name) = crate::file_handling::split_db_path(path).expect("a file's path");
let id = insert_file(
tx,
&NewFile {
name,
path,
parent,
size: 1,
mtime: 1,
@ -306,7 +299,7 @@ fn delete_subtree_clears_every_dependent_table() {
let survivors: Vec<String> = {
let mut stmt = conn
.prepare("SELECT path FROM files ORDER BY path")
.prepare("SELECT parent || name FROM files ORDER BY parent, name")
.unwrap();
let v = stmt
.query_map([], |r| r.get::<_, String>(0))
@ -327,13 +320,12 @@ fn seeded(conn: &mut Connection, paths: &[&str]) -> std::collections::HashMap<St
let tx = conn.transaction().unwrap();
let mut ids = std::collections::HashMap::new();
for path in paths {
let name = path.rsplit('/').next().unwrap();
let parent = &path[..path.rfind('/').unwrap()];
// The indexer's own split, so the separator stays with the parent.
let (parent, name) = crate::file_handling::split_db_path(path).expect("a file's path");
let id = insert_file(
&tx,
&NewFile {
name,
path,
parent,
size: 1,
mtime: 1,
@ -387,7 +379,7 @@ fn delete_outside_ranges_keeps_exactly_the_configured_roots() {
let survivors: Vec<String> = {
let mut stmt = conn
.prepare("SELECT path FROM files ORDER BY path")
.prepare("SELECT parent || name FROM files ORDER BY parent, name")
.unwrap();
let v = stmt
.query_map([], |r| r.get::<_, String>(0))
@ -607,40 +599,47 @@ fn rows_in_range_page_walks_the_range_once() {
let range = crate::file_handling::ExtractCursor::for_root("/t");
let mut seen = Vec::new();
let mut after = range.lo.clone();
// `(lo, "")`: no stored name is empty, so this sorts just below the
// range's first row.
let mut after = (range.lo.clone(), String::new());
loop {
let page = rows_in_range_page(&conn, &after, &range.hi, 2).unwrap();
let page = rows_in_range_page(&conn, &after.0, &after.1, &range.hi, 2).unwrap();
let Some(last) = page.last() else { break };
after = last.path.clone();
after = (last.parent.clone(), last.name.clone());
seen.extend(page.into_iter().map(|r| r.path));
}
assert_eq!(
seen,
vec!["/t/a.txt", "/t/deep/b.txt", "/t/deep/deeper/c.txt"],
"in path order, once each, and the prefix siblings are outside"
"in (parent, name) order, once each, and the prefix siblings are outside"
);
drop(conn);
std::fs::remove_file(&p).ok();
}
/// `idx_files_parent` carries `name` and `mtime` so `dir_rows` never touches
/// the table heap; trimming it back to `(parent)` would silently reintroduce
/// a row fetch per entry.
/// `dir_rows` must *seek* on `idx_files_parent`, never scan the table.
///
/// It is deliberately not index-only any more: the index stopped carrying
/// `mtime` when `UNIQUE(parent, name)` took over as the row key, so the plan
/// is a seek plus a row fetch per entry. See the index's own comment in
/// `schema.rs` for why that trade was taken — and note the thing this test
/// guards is the seek, which is what keeps the cost per *directory* rather
/// than per *tree*.
#[test]
fn dir_rows_is_served_entirely_from_the_index() {
fn dir_rows_seeks_the_parent_index() {
let p = tmp_path();
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let plan: String = conn
.query_row(
"EXPLAIN QUERY PLAN SELECT name, mtime FROM files WHERE parent = ?1",
params!["/some/dir"],
params!["/some/dir/"],
|r| r.get(3),
)
.unwrap();
assert!(
plan.contains("COVERING INDEX idx_files_parent"),
"dir_rows must be index-only, got: {}",
plan.contains("SEARCH") && plan.contains("idx_files_parent"),
"dir_rows must seek the parent index, got: {}",
plan
);
drop(conn);
@ -655,7 +654,7 @@ fn the_subtree_range_is_an_index_seek_not_a_scan() {
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let plan: String = conn
.query_row(
"EXPLAIN QUERY PLAN DELETE FROM files WHERE path >= ?1 AND path < ?2",
"EXPLAIN QUERY PLAN DELETE FROM files WHERE parent >= ?1 AND parent < ?2",
params!["/tree/", "/tree0"],
|r| r.get(3),
)
@ -669,6 +668,43 @@ fn the_subtree_range_is_an_index_seek_not_a_scan() {
std::fs::remove_file(&p).ok();
}
/// The keyset page must be one index walk: the row-value comparison against
/// `(parent, name)` is what lets SQLite seek straight to the cursor and read
/// forward, with no temp b-tree to satisfy the `ORDER BY`.
#[test]
fn the_reconcile_page_seeks_and_does_not_sort() {
let p = tmp_path();
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let mut stmt = conn
.prepare(
"EXPLAIN QUERY PLAN
SELECT id, parent, name, size, mime, content_state FROM files
WHERE (parent, name) > (?1, ?2) AND parent < ?3
ORDER BY parent, name
LIMIT ?4",
)
.unwrap();
let plan: Vec<String> = stmt
.query_map(params!["/tree/", "", "/tree0", 2], |r| r.get(3))
.unwrap()
.map(|r| r.unwrap())
.collect();
let plan = plan.join(" | ");
assert!(
plan.contains("SEARCH") && plan.contains("idx_files_parent"),
"keyset page must seek the parent index, got: {}",
plan
);
assert!(
!plan.contains("TEMP B-TREE"),
"the index order must satisfy the ORDER BY outright, got: {}",
plan
);
drop(stmt);
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn last_full_index_round_trip() {
let p = tmp_path();
@ -693,8 +729,7 @@ fn checkpoint_and_close_truncates_wal() {
&tx,
&NewFile {
name: "w.txt",
path: "/tmp/w.txt",
parent: "/tmp",
parent: "/tmp/",
size: 1,
mtime: 1,
mime: None,
@ -725,14 +760,12 @@ fn checkpoint_and_close_truncates_wal() {
fn seed_rows(conn: &mut Connection, range: std::ops::Range<usize>) {
let tx = conn.transaction().unwrap();
for i in range {
let path = format!("/tmp/bulk/{}.txt", i);
let name = format!("{}.txt", i);
let id = insert_file(
&tx,
&NewFile {
name: &name,
path: &path,
parent: "/tmp/bulk",
parent: "/tmp/bulk/",
size: 1,
mtime: 1,
mime: Some("text/plain"),
@ -815,14 +848,12 @@ fn a_busy_reader_defeats_the_autocheckpoint_but_not_a_forced_one() {
fn seed_bare(conn: &mut Connection, range: std::ops::Range<usize>) {
let tx = conn.transaction().unwrap();
for i in range {
let path = format!("/tmp/bare/{}.txt", i);
let name = format!("{}.txt", i);
insert_file(
&tx,
&NewFile {
name: &name,
path: &path,
parent: "/tmp/bare",
parent: "/tmp/bare/",
size: i as u64,
mtime: 1,
mime: None,
@ -979,8 +1010,7 @@ fn set_content_failed_writes_failed_table() {
&tx,
&NewFile {
name: "oops.bin",
path: "/tmp/oops.bin",
parent: "/tmp",
parent: "/tmp/",
size: 0,
mtime: 1,
mime: None,
@ -1019,13 +1049,11 @@ fn set_content_failed_writes_failed_table() {
/// Insert one row under `path`, born pending when `needs_content`.
fn insert_at(tx: &Transaction<'_>, path: &str, needs_content: bool) -> i64 {
let name = path.rsplit('/').next().unwrap();
let parent = &path[..path.rfind('/').unwrap()];
let (parent, name) = crate::file_handling::split_db_path(path).expect("a file's path");
insert_file(
tx,
&NewFile {
name,
path,
parent,
size: 1,
mtime: 1,
@ -1176,3 +1204,40 @@ fn prune_root_stats_drops_every_figure_of_a_dropped_root() {
drop(conn);
std::fs::remove_file(&p).ok();
}
/// The vanished-directory sweep walks `for_each_parent_in_range`, and its
/// range has to include the root's *own* directory or the files sitting
/// directly in a root are never reconciled.
///
/// It does now only because every stored parent ends in a separator: the root's
/// parent is spelled `/tree/`, which is the range's `lo` exactly. With the bare
/// `/tree` an older schema stored, it sorted *below* `lo` and the sweep skipped
/// it — harmless then only because a readable root is always in `seen_dirs`.
#[test]
fn the_parent_scan_reaches_the_roots_own_directory() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
seeded(
&mut conn,
&[
"/tree/top.txt", // directly in the root
"/tree/deep/b.txt", // a subdirectory
"/tree/deep/deeper/c.txt", // deeper still
"/tree2/outside.txt", // prefix sibling: outside
"/treeX/outside.txt", // and the LIKE-metacharacter neighbour
],
);
let range = crate::file_handling::ExtractCursor::for_root("/tree");
let mut seen = Vec::new();
for_each_parent_in_range(&conn, &range.lo, &range.hi, |parent| seen.push(parent)).unwrap();
seen.sort();
assert_eq!(
seen,
vec!["/tree/", "/tree/deep/", "/tree/deep/deeper/"],
"the root's own directory is in range, and the siblings are not"
);
drop(conn);
std::fs::remove_file(&p).ok();
}

View file

@ -130,7 +130,11 @@ pub const PRAGMAS_READONLY: &str = "
///
/// Two of these can exist per indexing root, so the cache size is multiplied
/// by the root count. 1 MiB is sized for the walk's queries, which each read
/// one range of `idx_files_parent` once and never revisit it. The feeder's
/// one range of `idx_files_parent` once and never revisit it — plus, since
/// that index stopped carrying `mtime`, one table-row fetch per entry in the
/// range. Those land on a handful of pages while a directory's rows stay
/// rowid-adjacent; **this is the number to raise** if a tree churned across
/// many incremental runs ever scatters them far enough to matter. The feeder's
/// paging is the same shape, but its one-off `count_extract_scope` at pass
/// start is not: that scans the root's whole path range fetching a row per
/// entry, so on a large root it is a cold read all the way through. It is
@ -156,10 +160,20 @@ CREATE TABLE schema_info (
value TEXT NOT NULL
);
-- **There is no `path` column.** A file's path is `parent || name`, and
-- storing it a third time cost ~43% of the per-row footprint: over a
-- 400k-file corpus (avg path 77 bytes, parent 59, name 17) ~394 bytes/row
-- against the ~226 below, or ~170 MB per million files. It also widened the
-- row by a third, and three of the search cascade's passes scan every row
-- on an encrypted index each extra page is an AES-CBC decrypt and an
-- HMAC-SHA512 verify. Reassembling a path is a `push_str` in the one place
-- that needs one (`file_handling::split_db_path` is the inverse).
--
-- `parent` always ends in the platform separator; see `dir_to_db_parent` for
-- why the whole design turns on that.
CREATE TABLE files (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
parent TEXT NOT NULL,
size INTEGER NOT NULL,
mtime INTEGER NOT NULL,
@ -169,15 +183,25 @@ CREATE TABLE files (
hash BLOB
);
-- Covering, not just `(parent)`. The walk's row prefetcher issues
-- `SELECT name, mtime FROM files WHERE parent = ?` once per directory the
-- hottest read in a full run and with the bare index that is an index probe
-- plus a table-row fetch per entry. Those fetches are cold by design: the walk
-- reader deliberately runs on a 1 MiB page cache (see `PRAGMAS_WALK_READER`).
-- Carrying `name` and `mtime` in the index makes it an index-only scan.
-- `parent` stays leading, so `SELECT DISTINCT parent` range scans and
-- `paths_in_dir` are unaffected.
CREATE INDEX idx_files_parent ON files(parent, name, mtime);
-- The identity of a row, and the only index `parent` needs.
--
-- It replaces both of what came before a `UNIQUE(path)` and a covering
-- `(parent, name, mtime)` and dropping the second is the deliberate half.
-- The walk's row prefetcher issues `SELECT name, mtime FROM files WHERE
-- parent = ?` once per directory, the hottest read in a full run, and without
-- `mtime` in the index that is a table-row fetch per entry. It is affordable
-- because the prefetcher is one thread ahead of four walk workers that each
-- spend a `stat` *and* a SHA-256 of the path per file (`crate::walk`), so it
-- has budget to spend; because a directory's rows are written in one batch and
-- so are rowid-adjacent; and because the row is now narrow enough that a
-- 1 MiB cache holds ~7,200 of them. `mtime` cannot simply be appended here
-- `UNIQUE(parent, name, mtime)` would let the same file be inserted twice
-- under two mtimes.
--
-- If the prefetcher is ever measured falling behind, raise
-- `PRAGMAS_WALK_READER` paid for out of the space this index no longer
-- occupies rather than restoring the covering one.
CREATE UNIQUE INDEX idx_files_parent ON files(parent, name);
CREATE INDEX idx_files_mtime ON files(mtime);
CREATE INDEX idx_files_type ON files(type);
CREATE INDEX idx_files_mime ON files(mime);

View file

@ -86,6 +86,34 @@ const ODF_SHEET: TextSpec = TextSpec {
separator: Some(' '),
};
/// The text an `&entity;` or `&#1234;` reference stands for.
///
/// quick-xml 0.41 reports a reference as its own event instead of resolving
/// it inside the surrounding `Text`, so a reader that ignores this event
/// silently drops every `&amp;`, `&lt;` and `&#8217;` from the document —
/// no error, just missing characters in the index. Only the five predefined
/// entities and numeric references are resolvable without a DTD; anything
/// else is a document-defined entity we cannot expand, and is skipped.
fn entity_text(raw: &str) -> Option<String> {
if let Some(digits) = raw.strip_prefix('#') {
let code = match digits.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => digits.parse::<u32>().ok()?,
};
let c = char::from_u32(code)?;
// `char::from_u32` accepts far more than XML's character production
// does: every C0 control but tab, newline and carriage return is
// forbidden, and `&#0;` in particular would put a literal NUL into the
// indexed text and from there into an FTS5 column. `None` here reaches
// the callers as the same "unknown entity" error an unexpandable name
// gets — a `failed_files` row naming the file, which is the visible
// outcome this extractor prefers to a quietly mangled document.
let legal = !c.is_control() || matches!(c, '\t' | '\n' | '\r');
return legal.then(|| String::from(c));
}
quick_xml::escape::resolve_predefined_entity(raw).map(String::from)
}
/// Append the text `spec` selects out of `xml` to `out`.
///
/// `in_text` is a flag rather than a depth count, which means a closing
@ -93,7 +121,13 @@ const ODF_SHEET: TextSpec = TextSpec {
/// open.
fn collect_xml_text(xml: &str, spec: &TextSpec, out: &mut String) -> Result<(), Box<dyn Error>> {
let mut reader = Reader::from_str(xml);
reader.trim_text(true);
// Deliberately no `trim_text`: it trims each *event*, and since 0.41 an
// entity reference splits the character data around it into separate
// events — so `Jack &amp; Jill` would come back as `Jack&Jill`, with the
// spaces trimmed off the ends of the two fragments. Nothing needs it
// either: whitespace between elements arrives while the `in_text`/`in_cell`
// flag is false and is ignored there, and whitespace *inside* a
// text-bearing element is content.
let mut buf = Vec::new();
let mut in_text = false;
@ -105,11 +139,23 @@ fn collect_xml_text(xml: &str, spec: &TextSpec, out: &mut String) -> Result<(),
}
}
Ok(Event::Text(e)) if in_text => {
out.push_str(&e.unescape()?);
out.push_str(&e.decode()?);
if let Some(sep) = spec.separator {
out.push(sep);
}
}
// An entity reference is its own event in 0.41; without this arm
// every `&amp;` in a document would vanish from the index.
Ok(Event::GeneralRef(e)) if in_text => {
let raw = e.decode()?;
// An entity nothing can expand is an error, as it was when
// `unescape` resolved these inline: dropping it would take
// characters out of the indexed text with nothing to show for
// it, and this reader has no DTD to define one with.
let text = entity_text(&raw)
.ok_or_else(|| format!("Error parsing XML: unknown entity &{};", raw))?;
out.push_str(&text);
}
Ok(Event::End(ref e)) => {
let name = e.name();
if spec.text.contains(&name.as_ref()) {
@ -235,18 +281,46 @@ fn shared_strings<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Vec<String> {
return Vec::new();
};
let mut reader = Reader::from_str(&xml);
reader.trim_text(true);
// Deliberately no `trim_text`: it trims each *event*, and since 0.41 an
// entity reference splits the character data around it into separate
// events — so `Jack &amp; Jill` would come back as `Jack&Jill`, with the
// spaces trimmed off the ends of the two fragments. Nothing needs it
// either: whitespace between elements arrives while the `in_text`/`in_cell`
// flag is false and is ignored there, and whitespace *inside* a
// text-bearing element is content.
let mut buf = Vec::new();
let mut strings = Vec::new();
let mut in_text = false;
// One `<t>` is one shared string, but it is not one event: an entity
// reference inside it arrives separately and splits the character data
// around it. Accumulated here and pushed on the closing tag, or a cell
// containing `&amp;` would become three table entries and every later
// index would point at the wrong one.
let mut current = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) if e.name().as_ref() == b"t" => in_text = true,
Ok(Event::Text(e)) if in_text => match e.unescape() {
Ok(s) => strings.push(s.into_owned()),
Ok(Event::Start(ref e)) if e.name().as_ref() == b"t" => {
in_text = true;
current.clear();
}
Ok(Event::Text(e)) if in_text => match e.decode() {
Ok(s) => current.push_str(&s),
Err(_) => return strings,
},
Ok(Event::End(ref e)) if e.name().as_ref() == b"t" => in_text = false,
Ok(Event::GeneralRef(e)) if in_text => {
// Unlike the other two readers this one cannot fail — a
// missing table is not an error here — so an entity nothing
// can expand is simply left out.
if let Ok(raw) = e.decode() {
if let Some(text) = entity_text(&raw) {
current.push_str(&text);
}
}
}
Ok(Event::End(ref e)) if e.name().as_ref() == b"t" => {
in_text = false;
strings.push(std::mem::take(&mut current));
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
@ -259,7 +333,13 @@ fn shared_strings<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Vec<String> {
/// rather than text of its own; every other type holds its value inline.
fn collect_sheet(xml: &str, strings: &[String], out: &mut String) -> Result<(), Box<dyn Error>> {
let mut reader = Reader::from_str(xml);
reader.trim_text(true);
// Deliberately no `trim_text`: it trims each *event*, and since 0.41 an
// entity reference splits the character data around it into separate
// events — so `Jack &amp; Jill` would come back as `Jack&Jill`, with the
// spaces trimmed off the ends of the two fragments. Nothing needs it
// either: whitespace between elements arrives while the `in_text`/`in_cell`
// flag is false and is ignored there, and whitespace *inside* a
// text-bearing element is content.
let mut buf = Vec::new();
let mut in_cell = false;
let mut cell_type = String::new();
@ -287,19 +367,49 @@ fn collect_sheet(xml: &str, strings: &[String], out: &mut String) -> Result<(),
}
}
Ok(Event::Text(e)) if in_cell => {
let text = e.unescape()?;
let text = e.decode()?;
if cell_type == "s" {
// A shared-string reference. An index past the end of the
// table is a corrupt workbook, not something to guess at.
if let Some(s) = text.parse::<usize>().ok().and_then(|i| strings.get(i)) {
//
// `trim` because this reader no longer sets `trim_text`
// (see the comment above): a generator that indents its
// XML hands `<v>` over as "\n 0\n", and an untrimmed
// parse would fail and drop the string with nothing to
// show for it. Whitespace around an integer index is not
// content, unlike whitespace inside a `<t>`.
if let Some(s) = text
.trim()
.parse::<usize>()
.ok()
.and_then(|i| strings.get(i))
{
out.push_str(s);
out.push(' ');
}
} else {
} else if !text.trim().is_empty() {
// Whitespace-only fragments are the indentation *between*
// a cell's child elements, which reaches this arm now that
// the reader no longer sets `trim_text`. Skipped rather
// than pushed: `in_cell` is a flag, so it cannot tell an
// indent from a value, and a cell whose entire content is
// whitespace contributes nothing to a search index either
// way. The value itself is pushed whole — no `trim` — so a
// deliberate `xml:space="preserve"` inline string keeps
// its shape.
out.push_str(&text);
out.push(' ');
}
}
// See `entity_text`. Only inline values can carry one: a `t="s"`
// cell's text is an integer index, and an entity inside it would
// be a corrupt workbook rather than a character to recover.
Ok(Event::GeneralRef(e)) if in_cell && cell_type != "s" => {
let raw = e.decode()?;
let text = entity_text(&raw)
.ok_or_else(|| format!("Error parsing XML: unknown entity &{};", raw))?;
out.push_str(&text);
}
Ok(Event::End(ref e)) => {
let name = e.name();
if name.as_ref() == b"c" {
@ -376,6 +486,111 @@ mod tests {
use super::*;
use std::io::Write;
/// Entity references must survive extraction.
///
/// quick-xml 0.41 reports `&amp;` as its own `GeneralRef` event instead of
/// resolving it into the surrounding text, so a reader that only handles
/// `Event::Text` loses the character with no error to show for it. This is
/// the test that makes that visible: it fails by producing "Blake Co"
/// rather than by failing to compile.
#[test]
fn entity_references_survive_extraction() {
let body = "<w:document><w:body><w:p><w:r>\
<w:t>Blake &amp; Co &lt;tags&gt; &#8217;24 &#x2019;25</w:t>\
</w:r></w:p></w:body></w:document>";
let path = container("docx-entities", "docx", &[("word/document.xml", body)]);
let out = OfficeExtractor.extract(&path).expect("extract");
assert!(
out.text.contains("Blake & Co"),
"predefined entity lost: {:?}",
out.text
);
assert!(
out.text.contains("<tags>"),
"angle-bracket entities lost: {:?}",
out.text
);
assert!(
out.text.contains('\u{2019}'),
"numeric entities lost: {:?}",
out.text
);
assert!(
!out.text.contains("&amp;") && !out.text.contains("&#"),
"entities left unresolved: {:?}",
out.text
);
}
/// The same, through the shared-string table an `.xlsx` cell indexes into
/// — a separate reader, and so a separate chance to drop the character.
#[test]
fn entity_references_survive_shared_strings() {
let shared = "<sst><si><t>Jack &amp; Jill</t></si></sst>";
let sheet = "<worksheet><sheetData><row>\
<c t=\"s\"><v>0</v></c></row></sheetData></worksheet>";
let path = container(
"xlsx-entities",
"xlsx",
&[
("xl/sharedStrings.xml", shared),
("xl/worksheets/sheet1.xml", sheet),
],
);
let out = OfficeExtractor.extract(&path).expect("extract");
assert!(
out.text.contains("Jack & Jill"),
"entity lost through the shared-string table: {:?}",
out.text
);
}
/// A shared-string reference must survive an indented `<v>`.
///
/// The reader deliberately does not set `trim_text` (an entity reference
/// splits the character data around it, and trimming each fragment would
/// eat the spaces at the split). A `t="s"` cell's `<v>` is an integer
/// index, though, so a generator that pretty-prints its sheet XML hands
/// this reader `"\n 0\n "` — and an untrimmed `parse::<usize>()`
/// fails, dropping the cell's text with no error and no `failed_files`
/// row. Whitespace-only fragments between a cell's children must not
/// reach the output either.
#[test]
fn an_indented_shared_string_reference_still_resolves() {
let shared = "<sst><si><t>Marmalade</t></si></sst>";
let sheet = "<worksheet>\n <sheetData>\n <row>\n \
<c t=\"s\">\n <v>\n 0\n </v>\n </c>\n \
<c t=\"n\">\n <v>17</v>\n </c>\n \
</row>\n </sheetData>\n</worksheet>";
let path = container(
"xlsx-indented",
"xlsx",
&[
("xl/sharedStrings.xml", shared),
("xl/worksheets/sheet1.xml", sheet),
],
);
let out = OfficeExtractor.extract(&path).expect("extract");
assert!(
out.text.contains("Marmalade"),
"the shared string was dropped by an indented index: {:?}",
out.text
);
assert!(
out.text.contains("17"),
"the inline value was dropped: {:?}",
out.text
);
// The indentation itself is not content: every run of whitespace in
// the output should be a separator this reader put there, never a
// line of the source XML's own layout.
assert!(
!out.text.contains("\n "),
"sheet indentation reached the indexed text: {:?}",
out.text
);
}
#[test]
fn supports_docx_and_friends() {
let e = OfficeExtractor;

View file

@ -104,7 +104,8 @@ pub fn process_batch_updates(
let updated = repo::update_file_basic(&tx, &rec.as_new_file()).map_err(|e| {
format!(
"Failed to update file record + clear stale content for {}: {}",
rec.path, e
rec.path(),
e
)
})?;
@ -115,7 +116,7 @@ pub fn process_batch_updates(
None => {
crate::log_warn!(
"no indexed row matched {} during update; inserting instead",
rec.path
rec.path()
);
repo::insert_file(&tx, &rec.as_new_file())
.map_err(|e| format!("Failed to insert file record: {}", e))?
@ -124,7 +125,7 @@ pub fn process_batch_updates(
};
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
let zstd = body_or_skip!(bodies, i, rec.path);
let zstd = body_or_skip!(bodies, i, rec.path());
repo::set_content_done(&tx, id, text, zstd)?;
}
}
@ -149,7 +150,11 @@ pub fn process_batch_inserts(
return Ok(());
}
for batch in files_to_insert.chunks(config.processing.batch_size) {
// `.max(1)`, as at every other use of this field: `chunks(0)` panics,
// and a panic here is on the indexing thread, before the arm that would
// publish `IndexingStatus::Error` — so a hand-edited `batch_size = 0`
// wedges indexing for the session while the UI still reads "Running".
for batch in files_to_insert.chunks(config.processing.batch_size.max(1)) {
if stop_flag.load(Ordering::Relaxed) {
return Ok(());
}
@ -170,7 +175,7 @@ pub fn process_batch_inserts(
let id = repo::insert_file(&tx, &rec.as_new_file())
.map_err(|e| format!("Failed to insert file record: {}", e))?;
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
let zstd = body_or_skip!(bodies, i, rec.path);
let zstd = body_or_skip!(bodies, i, rec.path());
repo::set_content_done(&tx, id, text, zstd)?;
}
}
@ -236,13 +241,19 @@ pub fn cleanup_stale_index_entries(
/// Keyset cursor bounding everything stored beneath one directory.
///
/// `lo`/`hi` are the half-open path range `[dir + SEP, dir + (SEP + 1))`, so
/// the pair is a pure index range on `UNIQUE(files.path)`.
/// `lo`/`hi` are the half-open range `[dir + SEP, dir + (SEP + 1))` over
/// `files.parent`, so the pair is a pure index range on `idx_files_parent`.
///
/// The separator must be the platform's own: `files.path` stores native
/// separators, and the successor of `/` (`0x2F`) is `'0'` while the successor
/// of `\` (`0x5C`) is `']'` — the Unix pair on Windows yields
/// `hi = "C:\Users\me0"`, which every stored path sorts *above*, silently
/// It covers `dir`'s own files as well as its subdirectories' because every
/// stored parent ends in a separator (see `dir_to_db_parent`): the files
/// directly in `dir` have parent `dir + SEP`, which is `lo` exactly. Without
/// that invariant `lo` would have to be the bare `dir`, and the range would
/// swallow siblings — `/a-b` sorts inside `["/a", "/a0")`.
///
/// The separator must be the platform's own: parents store native separators,
/// and the successor of `/` (`0x2F`) is `'0'` while the successor of `\`
/// (`0x5C`) is `']'` — the Unix pair on Windows yields
/// `hi = "C:\Users\me0"`, which every stored parent sorts *above*, silently
/// disabling content extraction and the vanished-directory sweep.
#[derive(Debug, Clone)]
pub struct ExtractCursor {
@ -299,7 +310,7 @@ pub fn mark_oversize_pending_na(
) -> Result<(), String> {
conn.execute(
"UPDATE files SET content_state = 3 \
WHERE content_state = 0 AND size > ?1 AND path >= ?2 AND path < ?3",
WHERE content_state = 0 AND size > ?1 AND parent >= ?2 AND parent < ?3",
rusqlite::params![max_text_file_size(config), cursor.lo, cursor.hi],
)
.map_err(|e| format!("mark oversize files NA: {}", e))?;
@ -322,7 +333,7 @@ pub fn count_extract_scope(
.query_row(
"SELECT COALESCE(SUM(content_state = 0 AND size <= ?1), 0), \
COALESCE(SUM(content_state = 1), 0) \
FROM files WHERE path >= ?2 AND path < ?3",
FROM files WHERE parent >= ?2 AND parent < ?3",
rusqlite::params![max_text_file_size(config), cursor.lo, cursor.hi],
|row| Ok((row.get(0)?, row.get(1)?)),
)

View file

@ -18,8 +18,13 @@ pub use batch::{
Stored,
};
pub use counting::count_tree_entries_fast;
pub use paths::{db_key_for_missing_path, filtered_dirs, filtered_walk, UnreadableDirs};
pub(crate) use paths::{normalize_root_string, path_to_db_string, warn_if_unrepresentable};
pub use paths::{
db_key_for_missing_path, filtered_dirs, filtered_walk, split_db_path, UnreadableDirs,
};
pub(crate) use paths::{
dir_to_db_parent, index_file_set, normalize_root_string, path_to_db_string,
warn_if_unrepresentable, INDEX_SIDECAR_SUFFIXES,
};
pub use records::{
classify_by_mtime, classify_for_indexing, content_extractable, decide_content,
extract_and_store, fts_finalize_after_text_indexing, hash_failure_counts, outcome_body,

View file

@ -1,6 +1,7 @@
//! Path ↔ `files.path` string normalization and the filtered walkdir
//! wrappers the reconcile passes use.
//! Path ↔ `files.parent`/`files.name` string normalization and the filtered
//! walkdir wrappers the reconcile passes use.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@ -8,7 +9,8 @@ use walkdir::{DirEntry, WalkDir};
use crate::config::IgnoreSet;
/// Render a path as the string stored in `files.path`.
/// Render a path in the spelling the index stores it in, which
/// [`split_db_path`] then splits into the `(parent, name)` key.
///
/// `Path::canonicalize` on Windows hands back extended-length paths; the
/// index stores plain ones. A UNC share canonicalizes to
@ -34,11 +36,55 @@ pub(crate) fn path_to_db_string(path: &Path) -> String {
}
}
/// What SQLite and we hang off the index's own filename.
///
/// `-wal`, `-shm` and `-journal` are SQLite's, spelled exactly as
/// `db::open` and the unlock screen already delete them; `.lock` is
/// [`crate::platform::IndexLock`]'s.
pub(crate) const INDEX_SIDECAR_SUFFIXES: [&str; 4] = ["-wal", "-shm", "-journal", ".lock"];
/// Every file belonging to the index at `db_path`, spelled the way the walk
/// spells the files it visits.
///
/// **Nothing may ever open one of these.** On POSIX, closing *any* descriptor
/// on an inode cancels every advisory lock the whole process holds on it, so a
/// walk worker that opens `index.sqlite-shm` to hash it destroys the DMS lock
/// SQLite took on that file — after which the next connection to attach, from
/// any process, truncates the wal-index to 3 bytes under our live mapping and
/// the next commit dies with SIGBUS. The same close cancels the main
/// database's own locks, which is SQLite's documented corruption hazard
/// (howtocorrupt.html §2.2). See [`crate::walk`], which prunes these before an
/// entry can become a candidate.
///
/// The *directory* is canonicalized rather than the files: `-wal`, `-shm` and
/// the lock come and go across a run, and `canonicalize` fails on a path that
/// is not there at the instant it is called. The directory outlives all of
/// them, so resolving it once and joining the names gives a stable answer that
/// does not depend on which sidecars happen to exist.
pub(crate) fn index_file_set(db_path: &Path) -> HashSet<PathBuf> {
let mut set = HashSet::new();
// An empty filename means `database_path` names a directory, not a file.
// Joining "" onto the parent would yield the directory itself and prune
// the entire tree below it.
let Some(name) = db_path.file_name().and_then(|s| s.to_str()) else {
return set;
};
let dir = db_path.parent().unwrap_or_else(|| Path::new("."));
let dir = PathBuf::from(path_to_db_string(
&dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()),
));
set.insert(dir.join(name));
for suffix in INDEX_SIDECAR_SUFFIXES {
set.insert(dir.join(format!("{}{}", name, suffix)));
}
set
}
/// Canonicalize a root string for storage/comparison. Multi-root strings
/// (newline-joined) fail canonicalize and pass through verbatim, which still
/// compares consistently.
///
/// This is the spelling `files.path` rows are prefixed with, so it is also the
/// This is the spelling stored parents are prefixed with, so it is also the
/// form roots must be compared in: `~/docs` and `/home/me/docs` name one root
/// and must not read as a change.
pub(crate) fn normalize_root_string(indexing_path: &str) -> String {
@ -49,11 +95,20 @@ pub(crate) fn normalize_root_string(indexing_path: &str) -> String {
}
/// Warn and report `true` for a path that cannot round-trip through
/// `files.path`.
/// the index.
///
/// Everything downstream reopens the file by that TEXT column, and
/// [`path_to_db_string`] is lossy: a non-UTF-8 name would be stored as a
/// path naming a file that does not exist. Such a file is skipped whole.
/// [`path_to_db_string`] is lossy: a non-UTF-8 name would be stored as a path
/// naming a *different* file — U+FFFD is an ordinary filename character, so
/// the lossy spelling is somebody's real name. Such a file is skipped whole.
///
/// This is the check for callers that arrive with a single path and no
/// listing to prune: [`prepare_file_record_from_path`] on the incremental
/// route. The full walk screens far earlier, on the directory entry itself
/// (`crate::walk::read_directory`), because by the time a path exists a bad
/// *directory* component has already been joined into every path beneath it.
///
/// [`prepare_file_record_from_path`]: super::prepare_file_record_from_path
pub(crate) fn warn_if_unrepresentable(path: &Path) -> bool {
if path.to_str().is_some() {
return false;
@ -73,13 +128,21 @@ fn starts_with_drive_letter(s: &str) -> bool {
matches!((it.next(), it.next()), (Some(c), Some(':')) if c.is_ascii_alphabetic())
}
/// The `files.path` key for a path that may no longer exist.
/// The stored spelling of a path that may no longer exist.
///
/// The insert side canonicalizes before storing, and plain `canonicalize`
/// fails on a path already gone — so this canonicalizes the deepest ancestor
/// that still resolves and re-joins the missing tail. On Linux a root reached
/// through a symlinked parent (`/home` → `/mnt/home`) makes every removal a
/// no-op without this.
///
/// **The caller must have screened `path` for representability.** This ends in
/// [`path_to_db_string`], which is lossy, and the answer is used to *delete* —
/// a row by path and then a whole subtree range beneath it. For a path that is
/// not valid UTF-8 the key returned names some other, real file, and deleting
/// by it takes that file's row and everything under it. There is nothing to
/// delete for such a path in any case: it could never have been indexed. See
/// [`crate::incremental::remove_paths`], which skips them.
pub fn db_key_for_missing_path(path: &Path) -> String {
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut cursor = path;
@ -104,12 +167,42 @@ pub fn db_key_for_missing_path(path: &Path) -> String {
}
}
/// Parent directory of a path as a UTF-8 string, empty if root.
pub(super) fn parent_str(path: &str) -> String {
Path::new(path)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
/// Render a directory as the string stored in `files.parent`.
///
/// **`files.parent` always ends in the platform separator**, and that is the
/// invariant the whole schema rests on:
///
/// * A file's path is `parent` concatenated with `name` — no separator logic
/// at the join, and so no special case for `/` or `C:\`, whose children
/// would otherwise be spelled `//x` and `C:\\x`.
/// * A root's subtree is the single range `[root + SEP, root + succ(SEP))`,
/// because the root's *own* parent is `root + SEP` rather than `root`. With
/// a bare `root` there is no such range: strings between `root` and
/// `root + SEP` are siblings (`/a-b` sorts inside `["/a", "/a0")`), so it
/// would take two predicates and a sibling would sneak in through either.
///
/// See [`crate::db::schema::SCHEMA_CURRENT`] for what that buys.
pub(crate) fn dir_to_db_parent(dir: &Path) -> String {
let mut s = path_to_db_string(dir);
// The platform's own separator only. On Unix `\` is an ordinary filename
// character, so a directory genuinely named `weird\` must still get its
// `/` — testing both separators would leave that row unjoinable.
if !s.ends_with(std::path::MAIN_SEPARATOR) {
s.push(std::path::MAIN_SEPARATOR);
}
s
}
/// Split a stored path into the `(parent, name)` pair the index keys on.
///
/// The separator stays with the parent, so `parent` + `name` is the original
/// string back — see [`dir_to_db_parent`]. `None` for anything that cannot be
/// a file's path: a bare relative name with no separator at all, or a string
/// that ends in one and so names a directory rather than a file.
pub fn split_db_path(path: &str) -> Option<(&str, &str)> {
let cut = path.rfind(std::path::MAIN_SEPARATOR)?;
let (parent, name) = path.split_at(cut + 1);
(!name.is_empty()).then_some((parent, name))
}
/// Paths a walk could not read, collected as it runs.

View file

@ -10,7 +10,6 @@ use std::time::UNIX_EPOCH;
use rusqlite::Connection;
use sha2::{Digest, Sha256};
use super::paths::parent_str;
use super::*;
use crate::config::Config;
use crate::db::repo::{self, NewFile};
@ -109,7 +108,9 @@ pub fn fts_finalize_after_text_indexing(conn: &Connection) {
#[derive(Debug, Clone)]
pub struct OwnedNewFile {
pub name: String,
pub path: String,
/// The containing directory, ending in the platform separator. With
/// [`OwnedNewFile::name`] it is both the row's key and, concatenated, its
/// path — see [`super::paths::dir_to_db_parent`].
pub parent: String,
pub size: u64,
pub mtime: u64,
@ -130,10 +131,15 @@ pub struct OwnedNewFile {
}
impl OwnedNewFile {
/// The file's path, rebuilt. Callers that only want it for a message
/// should say so — nothing stores this.
pub fn path(&self) -> String {
format!("{}{}", self.parent, self.name)
}
pub fn as_new_file(&self) -> NewFile<'_> {
NewFile {
name: &self.name,
path: &self.path,
parent: &self.parent,
size: self.size,
mtime: self.mtime,
@ -165,12 +171,15 @@ pub fn hash_failure_counts() -> (u64, u64) {
/// already holds. The single implementation behind both full-run batches
/// and incremental watcher updates.
///
/// `path` must already be canonical and in `files.path` spelling (see
/// `path` must already be canonical and in stored spelling (see
/// [`path_to_db_string`]), and must still name the file once parsed back into
/// a [`Path`] — this opens it by that string. A path that only survived
/// `to_string_lossy` does not qualify; callers holding the original [`Path`]
/// screen it with [`warn_if_unrepresentable`] first. Callers holding an
/// unresolved path want [`prepare_file_record_from_path`] instead.
/// `to_string_lossy` does not qualify: the lossy spelling of one name is the
/// real name of another, so it would hash and index the wrong file. The walk
/// screens for that on the directory entry, before the path is even built
/// (`crate::walk::read_directory`); [`prepare_file_record_from_path`], which
/// is what callers holding an unresolved path want, screens with
/// [`warn_if_unrepresentable`].
///
/// Returns `None` for anything that isn't a readable regular file, with a
/// warning when hashing fails.
@ -213,10 +222,11 @@ pub fn prepare_file_record(
}
};
let name = Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())?;
let parent = parent_str(path);
// One split for both halves of the key, and the separator stays with the
// parent so the two concatenate back into `path`. `None` here means the
// caller handed us something that cannot be a file's path at all.
let (parent, name) = split_db_path(path)?;
let (parent, name) = (parent.to_string(), name.to_string());
// Sniff from the bytes hashing already read; an empty head falls back to
// the extension.
let mime = guess_mime_from_head(Path::new(path), &head);
@ -255,7 +265,6 @@ pub fn prepare_file_record(
Some(OwnedNewFile {
name,
path: path.to_string(),
parent,
size,
mtime,

View file

@ -300,6 +300,41 @@ fn db_key_for_a_vanished_path_canonicalizes_what_remains() {
std::fs::remove_dir_all(&root).ok();
}
/// The hazard every screen in the codebase exists for, stated once here.
///
/// `path_to_db_string` is many-to-one, and the collapsed spelling is not
/// garbage — it is a perfectly ordinary filename that a *different* file can
/// really have. So the stored key for an unrepresentable path is another
/// file's key, and any use of it (open, hash, delete by path, delete a subtree
/// range) lands on that file instead. `warn_if_unrepresentable` is how a
/// caller holding a single path avoids ever building one.
#[test]
fn the_stored_spelling_of_an_unrepresentable_path_is_another_files_key() {
let dir = Path::new("/docs");
let bad = dir.join(crate::testutil::unrepresentable_name("report", ".txt"));
let twin = dir.join(crate::testutil::lossy_twin("report", ".txt"));
assert!(warn_if_unrepresentable(&bad));
assert!(
!warn_if_unrepresentable(&twin),
"the twin is an ordinary name and must pass"
);
assert_eq!(
path_to_db_string(&bad),
path_to_db_string(&twin),
"two different files, one stored key"
);
// And the same one component deeper, which is why a bad *directory* has to
// be pruned rather than walked: the collision is inherited by everything
// beneath it.
assert_eq!(
path_to_db_string(&bad.join("child.txt")),
path_to_db_string(&twin.join("child.txt"))
);
assert!(warn_if_unrepresentable(&bad.join("child.txt")));
}
#[test]
fn db_key_for_an_entirely_missing_path_falls_back_to_the_raw_spelling() {
let nowhere = Path::new("relative-thing-that-does-not-exist.txt");
@ -396,3 +431,97 @@ fn hash_covers_size_and_head_only() {
std::fs::remove_dir_all(&root).ok();
}
/// The invariant the whole schema rests on: `parent` and `name` concatenate
/// back into the path, with no separator logic at the join.
///
/// Checked against real paths built by `Path::join` rather than by string
/// formatting, so a platform whose separator is not `/` is tested too.
#[test]
fn a_split_path_concatenates_back_into_itself() {
let root = std::path::Path::new(if cfg!(windows) { r"C:\" } else { "/" });
let cases = [
root.join("a.txt"), // a file at the very root
root.join("home").join("me").join("x.md"), // the ordinary case
root.join("dir with spaces").join("y"),
root.join("weird.name").join("z.tar.gz"),
];
for path in cases {
let s = path_to_db_string(&path);
let (parent, name) = split_db_path(&s).expect("a file's path");
assert_eq!(
format!("{}{}", parent, name),
s,
"{:?} must round-trip through its (parent, name) key",
s
);
assert_eq!(
name,
path.file_name().unwrap().to_string_lossy(),
"the name half is the file name"
);
assert_eq!(
parent,
dir_to_db_parent(path.parent().unwrap()),
"the parent half is what `dir_to_db_parent` would store"
);
assert!(
parent.ends_with(MAIN_SEPARATOR),
"a stored parent always ends in a separator: {:?}",
parent
);
assert!(
!name.contains(MAIN_SEPARATOR),
"a stored name never contains one: {:?}",
name
);
}
}
/// A filesystem root already ends in a separator, so `dir_to_db_parent` must
/// not add a second one — otherwise every file directly at the root would be
/// stored under `//` (or `C:\\`) and never found again.
#[test]
fn a_root_directory_does_not_get_a_doubled_separator() {
let root = std::path::Path::new(if cfg!(windows) { r"C:\" } else { "/" });
let parent = dir_to_db_parent(root);
assert_eq!(parent, path_to_db_string(root));
assert!(!parent.ends_with(&format!("{}{}", MAIN_SEPARATOR, MAIN_SEPARATOR)));
// And the join still produces a path that names the same file.
assert_eq!(
format!("{}{}", parent, "a.txt"),
path_to_db_string(&root.join("a.txt"))
);
}
/// Strings that are not a file's path have no key, and must say so rather
/// than producing a half-formed one — every lookup helper reads `None` as
/// "not indexed".
#[test]
fn a_string_that_cannot_be_a_files_path_has_no_key() {
assert_eq!(split_db_path("bare-name.txt"), None, "no separator at all");
assert_eq!(
split_db_path(&format!("{}dir{}", MAIN_SEPARATOR, MAIN_SEPARATOR)),
None,
"trailing separator: names a directory, not a file"
);
assert_eq!(split_db_path(""), None);
}
/// On Unix a backslash is an ordinary filename character. Splitting on it
/// there would put half a name in the parent, and the row would be
/// unreachable by the path it was stored under.
#[test]
#[cfg(unix)]
fn a_backslash_is_just_a_character_on_unix() {
let (parent, name) = split_db_path(r"/home/me/back\slash.txt").expect("a file's path");
assert_eq!(parent, "/home/me/");
assert_eq!(name, r"back\slash.txt");
// Same on the directory side: a folder genuinely named `weird\` still
// gets its own separator appended.
assert_eq!(
dir_to_db_parent(std::path::Path::new(r"/tmp/weird\")),
r"/tmp/weird\/"
);
}

View file

@ -110,11 +110,17 @@ fn upsert_path(
// A moved-in tree surfaces as one directory event; walk it with
// the same filters as a full run.
//
// A non-UTF-8 path is a whole subtree missing from the index, so it
// is an error (the caller schedules a full run) rather than a quiet
// `Ok`.
// A path the index cannot spell, alongside the ignore and hidden
// short-circuits above: a genuine "nothing indexable here", not a
// failure.
//
// It reads as a whole subtree missing from the index, which it is —
// but reporting that as an error would set `needs_full_run`
// (`coordinator::inner::apply_pending`), and a full run screens the
// same subtree out for the same reason. The reindex could not fix it,
// so every write inside such a directory would buy another one.
let Some(root) = path.to_str() else {
return Err(format!("directory path is not valid UTF-8: {:?}", path));
return Ok(Applied::Done);
};
// Streamed, not collected: `mv` of a large tree is one event, and
// materialising its entries first is a `DirEntry` per file resident
@ -124,10 +130,18 @@ fn upsert_path(
//
// `skip` rather than re-testing every entry: `upsert_file` on an
// unchanged file is cheap but not free, and paying it again for
// everything already done would make a large tree quadratic in the
// number of turns it takes. The walk order is deterministic for an
// unchanged tree; if the tree does change under us the count is only
// an optimisation, and the next full run is what makes it exact.
// everything already done would cost a transaction and a hash per
// already-indexed file on every turn.
//
// Two honest limits on that. The walk itself is *not* skipped — the
// iterator still reads every directory it passes over (and on Windows
// stats every entry), so the traversal cost stays quadratic in the
// number of turns even though the writes do not. And the count is a
// position, not an identity: if the tree changes under us the entries
// shift, so `skip(n)` skips the wrong files and those get no row until
// the next full run. Both are acceptable for a moved-in tree, which is
// finite and static in the usual case; if either ever matters, resume
// by last-path rather than by count.
let mut done = budget.resume_from;
for entry in filtered_walk(
root,
@ -166,12 +180,12 @@ fn upsert_file(
let existing: Option<(i64, i64)> = tx
.query_row(
"SELECT id, mtime FROM files WHERE path = ?1",
rusqlite::params![rec.path],
"SELECT id, mtime FROM files WHERE parent = ?1 AND name = ?2",
rusqlite::params![rec.parent, rec.name],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
.map_err(|e| format!("lookup {}: {}", rec.path, e))?;
.map_err(|e| format!("lookup {}: {}", rec.path(), e))?;
let file_id = match existing {
Some((_, mtime)) if mtime.max(0) as u64 == rec.mtime => return Ok(()),
@ -200,7 +214,7 @@ fn upsert_file(
extract_and_store(
&tx,
file_id,
&rec.path,
&rec.path(),
rec.mime.as_deref(),
registry,
config,
@ -232,6 +246,14 @@ pub fn remove_paths(
.transaction()
.map_err(|e| format!("begin incremental tx: {}", e))?;
for path in batch {
// A path the index cannot spell was never indexed, so there is
// nothing here to delete — and `db_key_for_missing_path` is lossy,
// so going ahead would key the row of whichever *different* file
// owns the lossy spelling and delete it, plus its whole subtree
// range below.
if path.to_str().is_none() {
continue;
}
// The insert side stores a canonicalized path, so the raw event
// spelling is not a usable key — but the file is already gone, so
// `canonicalize` cannot be called on it directly either.
@ -327,7 +349,7 @@ mod tests {
fn row(&self, path: &str) -> Option<(i64, i64, i64)> {
self.conn
.query_row(
"SELECT id, mtime, content_state FROM files WHERE path = ?1",
"SELECT id, mtime, content_state FROM files WHERE parent || name = ?1",
rusqlite::params![path],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
@ -371,6 +393,48 @@ mod tests {
/// The collapse must not change what ends up deleted — only how much work
/// it takes to get there.
/// A resume point must not outlive the event it describes.
///
/// The coordinator prunes `resume_from` alongside the queues; this is the
/// half of that contract the module itself can state — resuming from a
/// count that belonged to some earlier walk skips real files, and they get
/// no row until the next full run.
#[test]
fn resuming_past_the_end_indexes_nothing_rather_than_the_wrong_files() {
let mut f = Fixture::new();
for i in 0..3 {
f.write(&format!("sub/f{i}.txt"), "body");
}
let sub = f.dir.join("sub");
// A count larger than the tree: every entry is skipped, and the result
// is an empty index rather than an arbitrary subset.
let outcome =
f.apply_resuming(&FsEvent::Create(sub.clone()), Duration::from_secs(3600), 99);
assert_eq!(outcome, Applied::Done);
assert_eq!(f.counts().0, 0);
// From zero — what a pruned resume point gives the next turn — the
// whole tree lands.
f.apply(&FsEvent::Create(sub));
assert_eq!(f.counts().0, 3);
}
/// `batch_size = 0` must not panic the writer.
///
/// `chunks(0)` panics, and this one runs on the indexing thread above the
/// arm that would publish `IndexingStatus::Error` — so before the clamp a
/// hand-edited zero wedged indexing for the session while the UI went on
/// reading "Running". Every sibling call site already had `.max(1)`.
#[test]
fn a_zero_batch_size_does_not_panic_the_writer() {
let mut f = Fixture::new();
f.config.processing.batch_size = 0;
f.write("a.txt", "body");
f.apply(&FsEvent::Create(f.dir.join("a.txt")));
assert_eq!(f.counts().0, 1, "the file should still be indexed");
}
/// A directory event is applied in slices, and a slice resumes where the
/// last one stopped instead of re-walking what it already did.
///
@ -635,6 +699,76 @@ mod tests {
assert!(f.row(&survivor).is_some());
}
/// A `Remove` for a path the index cannot spell must delete nothing.
///
/// `db_key_for_missing_path` ends in `path_to_db_string`, which is lossy,
/// and the key it returns is used twice: to delete a row by path, and as
/// the low end of a range that deletes everything beneath it. For an
/// unrepresentable path that key names a *different*, real file — so the
/// event would take that file's row and its whole subtree, for a file that
/// was never indexed in the first place.
#[test]
fn removing_an_unrepresentable_path_spares_its_lossy_twin() {
let mut f = Fixture::new();
let twin = crate::testutil::lossy_twin("report", ".txt");
let kept = f.write(&twin, "the file that must survive");
f.apply(&FsEvent::Create(kept.clone()));
let canonical = f.canonical(&kept);
assert!(f.row(&canonical).is_some(), "seeded");
// Never written to disk: the event alone is enough, and a Remove is
// for a path that is already gone in any case.
let bad = f
.dir
.join(crate::testutil::unrepresentable_name("report", ".txt"));
f.apply(&FsEvent::Remove(bad));
assert!(
f.row(&canonical).is_some(),
"the real file's row was deleted by an event for a different file"
);
assert_eq!(f.counts().0, 1);
}
/// A directory event for a path the index cannot spell is a quiet `Ok`,
/// not an error.
///
/// `coordinator::inner::apply_pending` turns any `Err` here into
/// `needs_full_run`. A full run screens the same subtree out for the same
/// reason, so the reindex could not fix anything — it would just run
/// again on the next write into that directory, forever. Live on Windows,
/// where a `\\wsl.localhost\` or Samba tree can hold such a directory and
/// the root watch is recursive.
#[test]
fn a_directory_event_for_an_unrepresentable_path_is_not_an_error() {
let mut f = Fixture::new();
let bad = f.dir.join(crate::testutil::unrepresentable_name("dir", ""));
if std::fs::create_dir_all(&bad).is_err() {
eprintln!("skipped: this filesystem will not store an unrepresentable name");
return;
}
std::fs::write(bad.join("inside.txt"), "body").unwrap();
let outcome = apply_fs_event(
&mut f.conn,
&FsEvent::Create(bad),
&f.config,
&f.ignore,
&f.registry,
&Budget {
deadline: Instant::now() + Duration::from_secs(3600),
cancel: &AtomicBool::new(false),
resume_from: 0,
},
);
assert_eq!(
outcome,
Ok(Applied::Done),
"an unindexable subtree is not a failure"
);
assert_eq!(f.counts().0, 0, "and nothing under it is indexed");
}
#[test]
fn oversize_files_get_content_na() {
let mut f = Fixture::new();

View file

@ -204,6 +204,20 @@ impl IndexingService {
}
pub fn stop_indexing(&self) -> Result<(), String> {
self.stop_indexing_inner(true)
}
/// [`Self::stop_indexing`] for a caller that is about to delete the file.
///
/// Skips the checkpoint. `checkpoint_truncate` copies the whole write-ahead
/// log into the database — up to `maximum_wal_size`, 512 MiB by default —
/// and the next two statements delete both. It is pure cost, and it is
/// paid on the coordinator's thread while the user waits for a rebuild.
fn stop_indexing_for_delete(&self) -> Result<(), String> {
self.stop_indexing_inner(false)
}
fn stop_indexing_inner(&self, checkpoint: bool) -> Result<(), String> {
self.command_tx
.send(IndexingCommand::Stop)
.map_err(|e| format!("Failed to send stop command: {}", e))?;
@ -226,15 +240,27 @@ impl IndexingService {
// Flush the WAL and release the shared connection. WAL mode itself
// stays on — it's the persistent journal mode for the index.
if let Some(db_conn_arc) = crate::lock_ok(&self.db_connection).take() {
if checkpoint {
let conn = crate::lock_ok(&db_conn_arc);
if let Err(e) = crate::db::repo::checkpoint_truncate(&conn) {
crate::log_warn!("{}", e);
}
}
}
Ok(())
}
/// Publish a failure that happened *outside* a run, so the status bar
/// shows it rather than only the log ring.
///
/// A failed "Rebuild index" is the case this exists for: the user asked
/// for something, it did not happen, and a warning in a tab they are not
/// looking at is not telling them.
pub fn report_error(&self, message: String) {
*crate::lock_ok(&self.status) = IndexingStatus::Error(message);
}
pub fn get_status(&self) -> IndexingStatus {
crate::lock_ok(&self.status).clone()
}
@ -279,7 +305,7 @@ impl IndexingService {
/// Stop indexing and delete the database file for a clean rebuild
pub fn delete_index_for_rebuild(&self, db_path: &str) -> Result<(), String> {
self.stop_indexing()
self.stop_indexing_for_delete()
.map_err(|e| format!("Failed to stop indexing: {}", e))?;
// Cut short the optimize pass too: the file about to be deleted is
// the file it holds open.
@ -308,8 +334,15 @@ impl IndexingService {
// [`db::bump_index_epoch`].
db::bump_index_epoch();
if std::path::Path::new(db_path).exists() {
std::fs::remove_file(db_path)
let path = std::path::Path::new(db_path);
if path.exists() {
// Retried, as every other delete of this file is: on Windows it
// fails while any handle is open, and the readers are exactly the
// ones a user has just been using — the search worker holds its
// connection for `IDLE_RELEASE` after the last keystroke. The
// caller releases that one first; this covers a scan or a
// duplicates pass that has not noticed yet.
crate::platform::remove_file_retrying(path)
.map_err(|e| format!("Failed to delete database file: {}", e))?;
}
for suffix in ["-wal", "-shm", "-journal"] {

View file

@ -3,6 +3,7 @@
use rusqlite::Connection;
use std::collections::HashSet;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
@ -74,6 +75,81 @@ fn wal_len(path: &str) -> u64 {
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
/// Free space at which a run gives up rather than keep writing.
///
/// Filling the volume the index sits on is not a clean failure. SQLite's
/// guard against a short wal-index only covers the moment that file is
/// *extended*; a later write goes through the `-shm` mmap, and a page fault
/// the filesystem cannot back is delivered as **SIGBUS**, which no `Result`
/// can catch. On a copy-on-write filesystem (btrfs, ZFS) even overwriting an
/// already-allocated page needs a new extent, so a full volume can take the
/// process down on a write to a page that has existed for hours. Stopping
/// with an error while there is still room is the only safe end.
const DISK_FLOOR: u64 = 128 * 1024 * 1024;
/// Share of the free space above [`DISK_FLOOR`] the log may occupy.
///
/// The log is what grows unboundedly between checkpoints, so its cap is the
/// figure that has to fit in what is left. A quarter leaves room for the
/// index's own growth, the FTS segments a merge writes beside it, and
/// whatever else on the machine wants the same volume.
const WAL_SHARE_OF_FREE: u64 = 4;
/// The configured checkpoint threshold, lowered to what the volume can
/// actually absorb.
///
/// `maximum_wal_size` is a stall-frequency knob chosen against a roomy disk;
/// on a nearly full one its 512 MiB default is more than everything left.
/// Checkpointing sooner costs some lock acquisitions and keeps the log inside
/// the space available — see [`DISK_FLOOR`] for why running out is not
/// survivable.
///
/// A configured `0` (forced checkpoints off) is bounded like any other value
/// rather than special-cased: the knob turns off a *performance* behaviour and
/// is not a licence to fill the disk. On a roomy volume the derived bound is
/// larger than any run's log, so `0` keeps its meaning without a second rule.
/// Unknown free space changes nothing.
fn wal_cap_for_volume(configured: u64, db_path: &Path) -> u64 {
let Some(free) = crate::platform::available_space(db_path) else {
return configured;
};
let effective = wal_cap_for_free(configured, free);
if effective != configured {
crate::log_info!(
"{} free where the index lives: forcing a WAL checkpoint every {} MiB \
instead of {}",
human_mib(free),
effective / (1024 * 1024),
match configured {
0 => "never".to_string(),
n => format!("{} MiB", n / (1024 * 1024)),
}
);
}
effective
}
/// The arithmetic of [`wal_cap_for_volume`], split from the syscall so it is
/// testable without a filesystem of a chosen size — the same split the rest of
/// the codebase makes for anything decidable without asking the kernel.
pub(super) fn wal_cap_for_free(configured: u64, free: u64) -> u64 {
let room = free.saturating_sub(DISK_FLOOR) / WAL_SHARE_OF_FREE;
// Never below the floor a configured value would be raised to: checkpoints
// more often than that cost more in lock acquisition than the log costs in
// space, and the in-run check is what actually stops a doomed run.
let capped = room.max(crate::config::MINIMUM_WAL_SIZE);
// `0` is "no cap", so it loses every `min` — hence the explicit arm.
if configured == 0 {
capped
} else {
configured.min(capped)
}
}
fn human_mib(bytes: u64) -> String {
format!("{} MiB", bytes / (1024 * 1024))
}
/// Flips an [`AtomicBool`] when dropped. Held by `run_indexing` so the
/// per-root count subprocesses die on every exit path of a run.
struct CancelOnDrop(Arc<AtomicBool>);
@ -780,10 +856,11 @@ impl IndexingService {
// Log size at which to force a checkpoint; see [`wal_len`] for why
// SQLite's autocheckpoint cannot be left to do this.
let wal_path = format!("{}-wal", db_path);
let wal_cap = match config.processing.maximum_wal_size {
let configured_cap = match config.processing.maximum_wal_size {
0 => 0,
n => n.max(crate::config::MINIMUM_WAL_SIZE),
};
let wal_cap = wal_cap_for_volume(configured_cap, Path::new(db_path));
let mut checkpoint_at = wal_cap;
// Walks first, one slice each, then a single extraction slice.
@ -852,6 +929,24 @@ impl IndexingService {
crate::log_warn!("{}", e);
}
}
// Only here, not every round: this is the moment the log is
// at its largest, and it costs one syscall per checkpoint
// rather than one per writer turn. The checkpoint above has
// just returned whatever it could, so what is left is the
// honest figure.
if let Some(free) = crate::platform::available_space(Path::new(db_path)) {
if free < DISK_FLOOR {
return Err(format!(
"Stopped: only {} free where the index lives ({}). \
Indexing needs room for its write-ahead log, and \
filling the disk can kill the process outright \
rather than fail cleanly. Free some space and run \
again what is already indexed is kept.",
human_mib(free),
db_path
));
}
}
// Re-armed from what is on disk: a checkpoint that lost the
// race then costs one attempt per further `wal_cap` of
// growth, not a retry every round.

View file

@ -166,8 +166,11 @@ fn an_extracting_turn_lands_its_leftovers_one_slice_at_a_time() {
&tx,
&NewFile {
name: &format!("f{}.txt", i),
path: &path.to_string_lossy(),
parent: &tree.to_string_lossy(),
// `dir_to_db_parent`, not `to_string_lossy`: a stored
// parent always ends in a separator, and a row spelled
// without one sorts below every `ExtractCursor` range that
// should contain it.
parent: &crate::file_handling::dir_to_db_parent(&tree),
size: 22,
mtime: 1,
mime: Some("text/plain"),
@ -670,3 +673,62 @@ fn a_run_with_no_roots_is_complete_rather_than_unknown() {
assert_eq!(o.processed, 0);
assert_eq!(o.total, Some(0));
}
/// A roomy volume leaves `maximum_wal_size` exactly as configured: this is a
/// safety valve, not a second tuning knob.
#[test]
fn a_roomy_volume_does_not_move_the_checkpoint_threshold() {
use super::pipeline::wal_cap_for_free;
let configured = 512 * 1024 * 1024;
assert_eq!(
wal_cap_for_free(configured, 500 * 1024 * 1024 * 1024),
configured
);
// Exactly enough: floor plus four times the log.
let just_enough = 128 * 1024 * 1024 + configured * 4;
assert_eq!(wal_cap_for_free(configured, just_enough), configured);
}
/// A tight volume checkpoints sooner, so the log cannot grow into the space
/// that is left. Running out is not a clean failure — the wal-index is reached
/// through an mmap, and a write the filesystem cannot back is a SIGBUS.
#[test]
fn a_tight_volume_lowers_the_checkpoint_threshold() {
use super::pipeline::wal_cap_for_free;
let configured = 512 * 1024 * 1024;
// 1 GiB free: 896 MiB above the floor, a quarter of which is 224 MiB.
let got = wal_cap_for_free(configured, 1024 * 1024 * 1024);
assert_eq!(got, 224 * 1024 * 1024);
assert!(got < configured);
}
/// `0` means "never force a checkpoint", which is a performance choice and not
/// a licence to fill the disk — so it is bounded by the volume like any other
/// value. On a roomy disk that bound is larger than any run's log, which is
/// how the setting keeps its meaning without a second rule to special-case it.
#[test]
fn disabled_checkpoints_are_still_bounded_by_the_volume() {
use super::pipeline::wal_cap_for_free;
let roomy = wal_cap_for_free(0, 500 * 1024 * 1024 * 1024);
assert!(
roomy > 100 * 1024 * 1024 * 1024,
"effectively never on a roomy disk, got {} bytes",
roomy
);
let tight = wal_cap_for_free(0, 1024 * 1024 * 1024);
assert_eq!(tight, 224 * 1024 * 1024, "a tight one checkpoints anyway");
}
/// Below the floor there is nothing sensible left to divide, and the run is
/// about to be stopped by the in-run check anyway — so the threshold bottoms
/// out at the same floor a configured value is raised to, never at zero.
#[test]
fn a_full_volume_bottoms_out_at_the_minimum_rather_than_zero() {
use super::pipeline::wal_cap_for_free;
for free in [0, 1024, 127 * 1024 * 1024] {
assert_eq!(
wal_cap_for_free(512 * 1024 * 1024, free),
crate::config::MINIMUM_WAL_SIZE
);
}
}

View file

@ -673,8 +673,16 @@ fn classify(
) {
use notify::event::{ModifyKind, RenameMode};
let key = |p: &PathBuf| p.to_string_lossy().into_owned();
let is_target = |p: &PathBuf| targets.contains_key(&key(p));
// `to_str`, not `to_string_lossy`. Targets are paths out of the index, so
// they are all representable, and a path that is *not* still has a lossy
// spelling — one that is a perfectly valid path for some other file. Keyed
// lossily, an event for a file we could never index would match a row we
// are displaying and fire a Gone or a Changed at it.
let target_key = |p: &PathBuf| {
p.to_str()
.filter(|k| targets.contains_key(*k))
.map(str::to_owned)
};
match event.kind {
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
@ -684,43 +692,55 @@ fn classify(
let (Some(from), Some(to)) = (event.paths.first(), event.paths.get(1)) else {
return;
};
if is_target(from) {
pending.insert(key(from), Op::Renamed(to.clone()));
} else if is_target(to) {
if let Some(from_key) = target_key(from) {
// A destination the index cannot spell cannot be recorded as a
// rename: the row would carry a path naming a different file,
// and the GUI opens rows by that path. The file did leave the
// searchable world, so `Gone` is the honest update — and the
// one the user would get anyway once the row was verified.
let op = match to.to_str() {
Some(_) => Op::Renamed(to.clone()),
None => Op::Gone,
};
pending.insert(from_key, op);
} else if let Some(to_key) = target_key(to) {
// The atomic-save shape: a temporary file renamed over a row
// we are watching. The row did not move; its contents changed.
pending.insert(key(to), Op::Changed);
pending.insert(to_key, Op::Changed);
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
for path in &event.paths {
if is_target(path) {
pending.insert(key(path), Op::Changed);
} else {
if let Some(key) = target_key(path) {
pending.insert(key, Op::Changed);
} else if path.to_str().is_some() {
// Screened for the same reason: an orphan is paired with a
// lone `Gone` into a `Renamed` below, so an unrepresentable
// one would arrive at the same bad destination.
orphan_to.push(path.clone());
}
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
for path in &event.paths {
if is_target(path) {
if let Some(key) = target_key(path) {
// Provisional; a Both in this same window upgrades it.
pending.entry(key(path)).or_insert(Op::Gone);
pending.entry(key).or_insert(Op::Gone);
}
}
}
EventKind::Remove(_) => {
for path in &event.paths {
if is_target(path) {
pending.insert(key(path), Op::Gone);
if let Some(key) = target_key(path) {
pending.insert(key, Op::Gone);
}
}
}
EventKind::Create(_) | EventKind::Modify(_) => {
for path in &event.paths {
if is_target(path) {
if let Some(key) = target_key(path) {
// A Create at a watched path un-deletes the row.
pending.insert(key(path), Op::Changed);
pending.insert(key, Op::Changed);
}
}
}

View file

@ -170,6 +170,92 @@ fn an_ambiguous_windows_window_reports_gone_rather_than_guessing() {
assert_eq!(decided.get("/docs/b.txt"), Some(&Op::Gone));
}
/// [`event`], for a path that cannot be spelled as a `&str`.
fn event_os(kind: EventKind, paths: &[PathBuf]) -> NotifyEvent {
NotifyEvent {
kind,
paths: paths.to_vec(),
attrs: Default::default(),
}
}
/// A path in the displayed set, and its unrepresentable neighbour that
/// `to_string_lossy` collapses onto exactly that spelling.
fn twin_pair() -> (String, PathBuf) {
let shown = format!("/docs/{}", crate::testutil::lossy_twin("report", ".txt"));
let bad = PathBuf::from("/docs").join(crate::testutil::unrepresentable_name("report", ".txt"));
assert_eq!(bad.to_string_lossy(), shown, "the two must collide");
(shown, bad)
}
/// A file the index could never hold still generates events, and its lossy
/// spelling is a displayed row's real path. Keyed lossily, every one of those
/// events lands on that row: a `Remove` of the file we cannot index would mark
/// a completely different file gone, on screen, while it sits there on disk.
#[test]
fn events_for_an_unrepresentable_path_never_touch_its_lossy_twin() {
let (shown, bad) = twin_pair();
let t = targets(&[&shown]);
let decided = window(
&t,
vec![
event_os(
EventKind::Remove(RemoveKind::File),
std::slice::from_ref(&bad),
),
event_os(
EventKind::Create(CreateKind::File),
std::slice::from_ref(&bad),
),
event_os(EventKind::Modify(ModifyKind::Any), &[bad]),
],
);
assert!(
decided.is_empty(),
"the displayed row must be untouched: {decided:?}"
);
}
/// Renamed *to* a name the index cannot spell, the row cannot keep a usable
/// path — `Op::Renamed` carries the destination, and the GUI opens rows by it,
/// so a lossy one would open some other file. It left the searchable world, so
/// the honest answer is `Gone`; dropping the event instead would leave a stale
/// row on screen until something else disturbed it.
#[test]
fn a_rename_to_an_unrepresentable_name_reports_gone() {
let (_, bad) = twin_pair();
let t = targets(&["/docs/a.txt"]);
let decided = window(
&t,
vec![event_os(
EventKind::Modify(ModifyKind::Name(RenameMode::Both)),
&[PathBuf::from("/docs/a.txt"), bad],
)],
);
assert_eq!(decided.get("/docs/a.txt"), Some(&Op::Gone));
}
/// The Windows shape of the same thing: the halves arrive separately, and a
/// lone `Gone` plus a lone arrival are paired into a rename. The arrival must
/// not be a path we cannot spell, or the pairing invents the same bad
/// destination the test above rejects.
#[test]
fn a_split_rename_is_not_paired_with_an_unrepresentable_arrival() {
let (_, bad) = twin_pair();
let t = targets(&["/docs/a.txt"]);
let decided = window(
&t,
vec![
event(
EventKind::Modify(ModifyKind::Name(RenameMode::From)),
&["/docs/a.txt"],
),
event_os(EventKind::Modify(ModifyKind::Name(RenameMode::To)), &[bad]),
],
);
assert_eq!(decided.get("/docs/a.txt"), Some(&Op::Gone));
}
/// A watched directory is full of files that are not on screen. None of them
/// may produce an update — live results never add rows.
#[test]

View file

@ -65,6 +65,24 @@ pub fn record(level: Level, message: String) {
Level::Warn => format!("Warning: {}", message),
Level::Info => message,
};
// Almost every line here names a path, and a path is whatever someone
// called a file. An escape sequence in one reaches a terminal three ways:
// the stderr write below, a user running the GUI from a shell, and the
// Logs tab's Copy button, which puts the ring on the clipboard for pasting
// into a bug report. Diagnostics are not data — nothing downstream needs
// these bytes exactly — so they are scrubbed unconditionally.
//
// Line breaks are collapsed *first*, because `scrub_controls` counts them
// as controls and would leave `U+FFFD` where a space belongs. One record
// is one line — the ring renders it that way and `writeln!` adds the only
// newline there should be — so a message that arrives multi-line, such as
// a nested error's chain of causes, is flattened rather than boxed.
let text = if text.contains(['\n', '\r']) {
text.replace(['\n', '\r'], " ")
} else {
text
};
let text = crate::textenc::scrub_controls(&text).into_owned();
let _ = writeln!(std::io::stderr(), "{}", text);
lock().push(level, text);
}

View file

@ -407,6 +407,15 @@ pub const WATCH_ROOTS_RECURSIVELY: bool = cfg!(windows);
/// other with `LIKE` would otherwise disagree with itself. `NOCASE` folds ASCII
/// only, which matches what `LIKE` does — non-ASCII paths stay case-sensitive
/// on both sides, consistently.
///
/// **No query interpolates this any more.** The folder filter was its last
/// caller, and it now needs one `LIKE` where it used to need `= COLLATE … OR
/// LIKE`. What is left is a *specification*: this constant and
/// `tests::collation_matches_like_case_folding` are where the rule is written
/// down, and the places that fold paths by hand —
/// [`PATHS_ARE_CASE_INSENSITIVE`], [`crate::config::IgnoreSet`],
/// [`crate::config::Config::is_index_file`] — are required to agree with it.
/// Deleting it would leave that agreement asserted nowhere.
pub const PATH_COLLATION: &str = if cfg!(windows) { "NOCASE" } else { "BINARY" };
/// Whether this platform's filesystem matches names without regard to case —
@ -573,6 +582,324 @@ pub fn open_regular_file(path: &Path) -> std::io::Result<std::fs::File> {
Ok(file)
}
/// Why [`IndexLock::acquire`] did not hand back a lock.
#[derive(Debug)]
pub enum LockError {
/// Another process holds it. `pid` is whatever the holder recorded in the
/// file and is for the message only — it is never what decides.
Held { pid: Option<u32> },
/// The filesystem does not do locks. The caller must carry on regardless;
/// see [`IndexLock::acquire`].
Unsupported(String),
}
/// Proof that this process, and no other, owns the index at a given path.
///
/// Held for the life of the process: dropping it, or exiting, releases the
/// lock.
#[derive(Debug)]
pub struct IndexLock {
/// The lock lives on this open file description. Keeping the handle alive
/// is the whole mechanism, which is why the field is never read.
_file: std::fs::File,
path: PathBuf,
}
/// The lock this process is holding, if any.
///
/// A `static` because that is what the thing models: `flock` and `LockFileEx`
/// are owned by an open file description, one index is locked per process, and
/// what releases it is the process ending. Threading an `Arc<Mutex<_>>` from
/// `main` down through the unlock gate and into the settings handler would say
/// the same thing in three more places.
///
/// Never dropped — statics are not — and that costs nothing here: the kernel
/// releases the lock however the process goes away, which is the whole design.
static HELD_LOCK: std::sync::Mutex<Option<IndexLock>> = std::sync::Mutex::new(None);
impl IndexLock {
/// The lock file for the index at `db_path`.
///
/// Its own name, never the database or one of SQLite's sidecars: a
/// `flock` of ours on an inode SQLite also locks would be a second
/// locking protocol on one file, and on Unix our `close` of it would
/// cancel SQLite's locks — the very failure this whole change exists to
/// prevent.
pub fn path_for(db_path: &Path) -> PathBuf {
let name = db_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("index.sqlite");
db_path.with_file_name(format!("{}.lock", name))
}
/// Take the index lock, or report who has it.
///
/// **The guard is the kernel's lock, never the file's existence.** `flock`
/// and `LockFileEx` are held by the open file description, so the kernel
/// drops them when the process goes away for *any* reason — a clean exit,
/// a panic, SIGKILL, the OOM killer, a SIGBUS, or the power going out. A
/// `.lock` file left behind by an unclean shutdown is therefore inert, and
/// the next start re-acquires it normally. Nothing here may ever branch on
/// the file being present; a stale-PID-file scheme would strand the user
/// behind a crash exactly when they most want to reopen the app.
///
/// [`LockError::Unsupported`] means the filesystem could not answer, not
/// that the lock is taken — NFS without `lockd`, some FUSE mounts, a few
/// network shares. Callers **must** start anyway: a convenience guard is
/// never a good enough reason to refuse to open.
pub fn acquire(db_path: &Path) -> Result<IndexLock, LockError> {
let path = IndexLock::path_for(db_path);
if let Some(dir) = path.parent() {
if !dir.as_os_str().is_empty() {
let _ = create_dir_private(dir);
}
}
let file = match std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
{
Ok(f) => f,
// A read-only or unwritable index directory is not a second
// instance. Same rule as an unsupported filesystem: carry on.
Err(e) => return Err(LockError::Unsupported(format!("{}: {}", path.display(), e))),
};
lock_exclusive_nonblocking(&file).map_err(|e| match e {
LockAttempt::Held => LockError::Held {
pid: read_recorded_pid(&path),
},
LockAttempt::Unsupported(msg) => LockError::Unsupported(msg),
})?;
let lock = IndexLock {
_file: file,
path: path.clone(),
};
// Only after the lock is ours, and only so a second instance can name
// us in its message. Nothing reads this to make a decision.
lock.record_holder();
Ok(lock)
}
/// [`IndexLock::acquire`], keeping the lock in [`HELD_LOCK`] for the life
/// of the process. What a frontend calls at startup.
pub fn hold(db_path: &Path) -> Result<(), LockError> {
let lock = IndexLock::acquire(db_path)?;
*crate::lock_ok(&HELD_LOCK) = Some(lock);
Ok(())
}
/// Move the held lock onto the index at `db_path`, for a `database_path`
/// that changed while the app was running.
///
/// **The new lock is taken before the old one is let go.** A refusal then
/// leaves this process holding exactly what it held before, so the caller
/// can reject the change and stay in a state that is still true. Releasing
/// first would leave a window in which another instance could claim the
/// index we are about to keep using.
///
/// Naming the index already locked is [`LockError::Held`] and changes
/// nothing. [`LockError::Unsupported`] means the move *happened* — the old
/// lock is gone — and the new path simply cannot be locked; carry on, as at
/// startup. Holding the old path's lock in that case would be worse than
/// holding none: it guards a file this process no longer uses while barring
/// a second instance from it.
pub fn move_to(db_path: &Path) -> Result<(), LockError> {
let mut slot = crate::lock_ok(&HELD_LOCK);
// Short-circuit before acquiring: `flock` conflicts with itself across
// two open file descriptions even inside one process, so re-taking the
// path we already hold would report itself as `Held`.
if slot
.as_ref()
.is_some_and(|held| held.path == IndexLock::path_for(db_path))
{
return Ok(());
}
match IndexLock::acquire(db_path) {
Ok(lock) => {
*slot = Some(lock);
Ok(())
}
Err(LockError::Unsupported(why)) => {
*slot = None;
Err(LockError::Unsupported(why))
}
Err(e) => Err(e),
}
}
fn record_holder(&self) {
use std::io::Write;
// A fresh handle: writing through `_file` would move the shared file
// offset the lock has nothing to do with, and the failure here is
// cosmetic either way. That a *second* handle can write here at all is
// why the Windows lock byte sits at [`LOCK_BYTE_OFFSET`] rather than at
// offset 0 — its byte-range locks are mandatory and per-handle, so a
// lock over this byte would block our own write.
let written = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&self.path)
.and_then(|mut f| write!(f, "{}", std::process::id()));
if let Err(e) = written {
crate::log_warn!(
"could not record the lock holder in {}: {}",
self.path.display(),
e
);
}
}
}
/// The PID a holder recorded, for a message. `None` whenever the file is
/// absent, empty or unparseable — all of which are ordinary.
fn read_recorded_pid(path: &Path) -> Option<u32> {
std::fs::read_to_string(path).ok()?.trim().parse().ok()
}
/// What one attempt at the lock found.
enum LockAttempt {
Held,
Unsupported(String),
}
/// Where the Windows lock byte lives, chosen to be nowhere near the PID the
/// lock file holds at offset 0. See [`lock_exclusive_nonblocking`]'s Windows
/// arm; the Unix arm has no equivalent, because `flock` locks the open file
/// description rather than a byte range.
#[cfg(windows)]
const LOCK_BYTE_OFFSET: u64 = 1 << 63;
#[cfg(unix)]
fn lock_exclusive_nonblocking(file: &std::fs::File) -> Result<(), LockAttempt> {
use std::os::unix::io::AsRawFd;
// `flock`, not `fcntl`: a POSIX record lock would be cancelled by any
// `close` this process makes on the same inode, which is the hazard
// documented in `file_handling::index_file_set`. A `flock` belongs to the
// open file description and is immune to it.
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
return Ok(());
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EWOULDBLOCK) => Err(LockAttempt::Held),
_ => Err(LockAttempt::Unsupported(err.to_string())),
}
}
#[cfg(windows)]
fn lock_exclusive_nonblocking(file: &std::fs::File) -> Result<(), LockAttempt> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
use windows_sys::Win32::Storage::FileSystem::{
LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY,
};
use windows_sys::Win32::System::IO::{OVERLAPPED, OVERLAPPED_0, OVERLAPPED_0_0};
// The locked byte sits at [`LOCK_BYTE_OFFSET`], far past anything this
// file will ever hold — **not** at offset 0, where the PID is written.
// Windows byte-range locks are *mandatory* and belong to the file object,
// not the process: a lock covering offset 0 makes our own
// `record_holder`, which writes through a second handle, fail with
// `ERROR_LOCK_VIOLATION`, and `read_recorded_pid` fail the same way — so
// the "already running" message would lose the PID on the one platform
// that has no terminal to print it to. Locking beyond end-of-file is
// explicitly legal and is the conventional way to use a file as a
// semaphore.
let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
overlapped.Anonymous = OVERLAPPED_0 {
Anonymous: OVERLAPPED_0_0 {
Offset: LOCK_BYTE_OFFSET as u32,
OffsetHigh: (LOCK_BYTE_OFFSET >> 32) as u32,
},
};
let ok = unsafe {
LockFileEx(
file.as_raw_handle() as _,
LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
0,
1,
0,
&mut overlapped,
)
};
if ok != 0 {
return Ok(());
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(code) if code == ERROR_LOCK_VIOLATION as i32 => Err(LockAttempt::Held),
_ => Err(LockAttempt::Unsupported(err.to_string())),
}
}
/// Bytes free to this user on the filesystem holding `path`, or `None` where
/// the platform will not say.
///
/// `None` is "unknown", never "zero": every caller treats it as no reason to
/// hold anything back.
///
/// The path need not exist — its nearest existing ancestor is what gets
/// asked, so this answers for a database that has not been created yet.
pub fn available_space(path: &Path) -> Option<u64> {
let mut probe = path;
loop {
if probe.exists() {
break;
}
probe = probe.parent()?;
}
available_space_of_existing(probe)
}
#[cfg(unix)]
fn available_space_of_existing(path: &Path) -> Option<u64> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
return None;
}
// `f_bavail`, not `f_bfree`: the reserved blocks in the difference are
// root's, and this process is not root. `f_frsize` is the fragment size
// the block counts are in — `f_bsize` is the preferred I/O size and is
// the wrong multiplier.
let frsize = if stat.f_frsize > 0 {
stat.f_frsize
} else {
stat.f_bsize
};
(stat.f_bavail as u64).checked_mul(frsize as u64)
}
#[cfg(windows)]
fn available_space_of_existing(path: &Path) -> Option<u64> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut free_to_caller: u64 = 0;
let ok = unsafe {
GetDiskFreeSpaceExW(
wide.as_ptr(),
&mut free_to_caller,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
(ok != 0).then_some(free_to_caller)
}
/// How long to keep retrying a delete that fails because something else holds
/// the file open.
#[cfg(windows)]

View file

@ -1,5 +1,7 @@
use super::*;
use std::time::{Duration, Instant};
#[test]
fn unc_spellings() {
assert!(is_unc_string(r"\\server\share"));
@ -242,3 +244,240 @@ fn sep_prefix() -> String {
"/".to_string()
}
}
/// Two live instances cannot both hold the index.
#[test]
fn the_index_lock_is_exclusive_while_held() {
let db = crate::testutil::scratch_dir("lock-excl").join("index.sqlite");
let first = IndexLock::acquire(&db).expect("first acquire");
match IndexLock::acquire(&db) {
Err(LockError::Held { pid }) => {
// Recorded for the message only, but it should name us.
assert_eq!(pid, Some(std::process::id()));
}
Err(LockError::Unsupported(why)) => {
// A filesystem with no locks cannot answer; nothing to assert.
eprintln!("skipping: {}", why);
}
Ok(_) => panic!("the lock was handed out twice"),
}
drop(first);
// And it comes back once the holder lets go — but not necessarily in the
// same instant, which is why this retries instead of asserting outright.
//
// `flock` belongs to the *open file description*, and `fork` duplicates
// the descriptor table: between another thread's `fork` and its `exec`,
// the child shares every description this process has open, including the
// one we just released. `O_CLOEXEC` closes it at `exec` — verified, no
// descriptor survives into a spawned child — but until then the lock
// stays held. Several tests in this suite spawn processes (the sibling
// test below, and `file_handling::counting`'s `find`/`wc`), so under
// `cargo test` this window is reached often enough to be seen.
//
// It cannot reach the product: the lock is taken once at startup and held
// for the life of the process, never dropped and immediately retaken.
acquire_within(&db, Duration::from_secs(5));
}
/// [`IndexLock::acquire`], retried past the `fork`/`exec` window described in
/// [`the_index_lock_is_exclusive_while_held`].
fn acquire_within(db: &std::path::Path, budget: Duration) -> IndexLock {
let deadline = Instant::now() + budget;
loop {
match IndexLock::acquire(db) {
Ok(lock) => return lock,
Err(e) if Instant::now() >= deadline => panic!("never acquired: {:?}", e),
Err(_) => std::thread::sleep(Duration::from_millis(10)),
}
}
}
/// Env var naming the database whose lock [`lock_holder_child`] should take.
/// Absent in an ordinary run, which is what makes that test a no-op.
const LOCK_CHILD_DB: &str = "QS_LOCK_CHILD_DB";
/// **A crash must not lock the user out.**
///
/// Nothing unlinks the lock file, so it outlives an unclean exit. If startup
/// keyed on the file *existing*, one SIGKILL — or the SIGBUS this whole change
/// is about — would leave QuickSearch permanently unopenable. The guard is the
/// kernel's `flock`/`LockFileEx`, released when the holder's handle goes away
/// however it goes away, so the leftover file is inert.
///
/// Only a real killed process proves that, so this spawns one: nothing a
/// single process can do to itself reproduces "died without running a
/// destructor" while leaving a test alive to check the result.
#[test]
fn a_killed_holder_does_not_block_the_next_start() {
let db = crate::testutil::scratch_dir("lock-crash").join("index.sqlite");
let lock_path = IndexLock::path_for(&db);
// Probe first: on a filesystem without locks there is nothing to test.
match IndexLock::acquire(&db) {
Ok(lock) => drop(lock),
Err(LockError::Unsupported(why)) => {
eprintln!("skipping: {}", why);
return;
}
Err(LockError::Held { .. }) => panic!("a fresh path cannot be held"),
}
let exe = std::env::current_exe().expect("test binary path");
let mut child = std::process::Command::new(exe)
.args([
"--exact",
"platform::tests::lock_holder_child",
"--nocapture",
])
.env(LOCK_CHILD_DB, &db)
.spawn()
.expect("spawn the lock holder");
// Poll rather than read a pipe: a child that dies early then fails this
// test at the deadline instead of hanging it forever.
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if matches!(IndexLock::acquire(&db), Err(LockError::Held { .. })) {
break;
}
if Instant::now() >= deadline {
let _ = child.kill();
panic!("the child never took the lock");
}
std::thread::sleep(Duration::from_millis(25));
}
// SIGKILL / TerminateProcess: no unwinding, no destructors, no cleanup —
// exactly what a SIGBUS leaves behind.
child.kill().expect("kill the holder");
child.wait().expect("reap the holder");
assert!(
lock_path.exists(),
"the crash should leave the lock file at {}",
lock_path.display()
);
// The point of the whole test: file present, holder dead, start succeeds.
// Retried for the reason `acquire_within` documents, not because a dead
// holder could still be holding anything.
acquire_within(&db, Duration::from_secs(5));
}
/// The child half of [`a_killed_holder_does_not_block_the_next_start`]: take
/// the lock, then wait to be killed. A no-op in an ordinary run.
#[test]
fn lock_holder_child() {
let Some(db) = std::env::var_os(LOCK_CHILD_DB) else {
return;
};
let db = std::path::PathBuf::from(db);
// The parent probes the lock to find out when we have it, so it may hold
// it for an instant just as we ask. Retry rather than lose the race.
let deadline = Instant::now() + Duration::from_secs(30);
let _lock = loop {
match IndexLock::acquire(&db) {
Ok(lock) => break lock,
Err(e) if Instant::now() >= deadline => panic!("child never acquired: {:?}", e),
Err(_) => std::thread::sleep(Duration::from_millis(10)),
}
};
// Killed long before this returns. The sleep is a backstop so a parent
// that dies first cannot strand this process.
std::thread::sleep(Duration::from_secs(120));
}
/// A `database_path` changed in Settings must carry the lock with it — and a
/// refused move must leave this process holding exactly what it held.
///
/// The whole point of taking the new lock before dropping the old: if the
/// destination is already somebody else's, the settings change is rejected and
/// the app goes on using the index it was using, still guarded. Releasing
/// first would open a window on the index we are about to keep writing to.
///
/// Uses the process-wide slot, so it is the one test that touches
/// [`HELD_LOCK`]; the paths are per-test scratch directories, so it does not
/// race the sibling tests that call [`IndexLock::acquire`] directly.
#[test]
fn the_held_lock_follows_the_database_path() {
let dir = crate::testutil::scratch_dir("lock-move");
let first = dir.join("first.sqlite");
let second = dir.join("second.sqlite");
// A filesystem with no locks cannot answer any of this.
match IndexLock::hold(&first) {
Ok(()) => {}
Err(LockError::Unsupported(why)) => {
eprintln!("skipping: {}", why);
return;
}
Err(LockError::Held { .. }) => panic!("a fresh path cannot be held"),
}
// Naming the index we already hold is a no-op, not a self-collision:
// `flock` conflicts with itself across two descriptions in one process.
IndexLock::move_to(&first).expect("re-holding the same path");
// Somebody else owns the destination, so the move is refused...
let rival = acquire_within(&second, Duration::from_secs(5));
assert!(
matches!(IndexLock::move_to(&second), Err(LockError::Held { .. })),
"a held destination must refuse the move"
);
// ...and the old path is still ours, which is what lets the caller reject
// the settings change and stay correct.
assert!(
matches!(IndexLock::acquire(&first), Err(LockError::Held { .. })),
"the original lock must survive a refused move"
);
// Once the destination frees up the move goes through, and the path we
// came from is released.
drop(rival);
let deadline = Instant::now() + Duration::from_secs(5);
while IndexLock::move_to(&second).is_err() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
matches!(IndexLock::acquire(&second), Err(LockError::Held { .. })),
"the new path must be held after the move"
);
acquire_within(&first, Duration::from_secs(5));
}
/// `database_path` in hand, the lock is a sibling with its own name — never
/// the database or one of SQLite's sidecars, whose inodes we must not touch.
#[test]
fn the_lock_file_is_not_the_database_or_a_sidecar() {
let db = std::path::Path::new("/var/lib/qs/index.sqlite");
let lock = IndexLock::path_for(db);
assert_eq!(lock, std::path::Path::new("/var/lib/qs/index.sqlite.lock"));
for suffix in crate::file_handling::INDEX_SIDECAR_SUFFIXES {
if suffix == ".lock" {
continue;
}
assert_ne!(
lock,
std::path::PathBuf::from(format!("{}{}", db.display(), suffix))
);
}
assert_ne!(lock, db);
}
#[test]
fn available_space_answers_for_a_real_directory() {
let dir = crate::testutil::scratch_dir("space");
let free = available_space(&dir).expect("temp dir has a filesystem");
assert!(free > 0, "a writable scratch dir should have free space");
}
/// The database is asked about before it exists — the check runs at the start
/// of the first run, when nothing has created the file yet.
#[test]
fn available_space_walks_up_to_an_existing_ancestor() {
let missing = crate::testutil::scratch_dir("space-missing")
.join("not")
.join("created")
.join("index.sqlite");
assert!(!missing.exists());
assert!(available_space(&missing).is_some());
}

View file

@ -291,11 +291,14 @@ mod tests {
fn a_windows_drive_path_reaches_the_filter_intact() {
let q = split_for_cascade(r"path:C:\Users\me\docs").unwrap();
assert_eq!(q.term, "", "the whole input is a filter");
assert!(
matches!(
&q.filter_params[0],
Value::Text(t) if t == r"C:\Users\me\docs"
),
// The folder filter binds one value: the subtree LIKE pattern built
// from the spelling the user typed. Asserted through the builder so
// this stays about the plumbing rather than about escaping rules.
assert_eq!(
q.filter_params[0],
Value::Text(crate::query::translator::like_subtree_pattern(
r"C:\Users\me\docs"
)),
"{:?}",
q.filter_params
);
@ -307,7 +310,9 @@ mod tests {
assert_eq!(q.term, "");
assert_eq!(
q.filter_params[0],
Value::Text("/home/me/My Documents".into())
Value::Text(crate::query::translator::like_subtree_pattern(
"/home/me/My Documents"
))
);
}
@ -439,9 +444,12 @@ mod tests {
let q = split_for_cascade("name:%*_").unwrap();
assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%\\%%\\_%"));
// path: values never glob.
// path: values never glob — the star reaches the pattern as a literal.
let q = split_for_cascade("path:/da*ta").unwrap();
assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "/da*ta"));
assert_eq!(
q.filter_params[0],
Value::Text(crate::query::translator::like_subtree_pattern("/da*ta"))
);
}
#[test]

View file

@ -146,18 +146,15 @@ pub fn build_filter(
// single root, so say it directly rather than by accident.
return Ok(frag("1=1", Vec::new()));
}
// The `=` half needs the collation spelled out: `LIKE` folds ASCII
// case on its own, so without this the two halves of the same
// filter disagree about `C:\Users` versus `c:\users`.
// One `LIKE`, where this used to need `parent = ? OR parent LIKE ?`
// with the collation spelled out to stop the two halves disagreeing
// about `C:\Users` versus `c:\users`. Every stored parent now ends
// in a separator, so `dir + SEP + %` matches the folder's own files
// (`%` matching nothing) as well as its subdirectories', and the
// `=` half has nothing left to do.
Ok(frag(
&format!(
"(f.parent = ? COLLATE {} OR f.parent LIKE ? ESCAPE '\\')",
crate::platform::PATH_COLLATION
),
vec![
Value::Text(base.clone()),
Value::Text(like_subtree_pattern(&base)),
],
"f.parent LIKE ? ESCAPE '\\'",
vec![Value::Text(like_subtree_pattern(&base))],
))
}
"name" | "filename" => {
@ -204,21 +201,23 @@ pub fn escape_like(s: &str) -> String {
out
}
/// Tidy a user-supplied folder value into the spelling `files.parent` stores.
/// Tidy a user-supplied folder value: trim it, and drop any trailing
/// separator, of either flavour — that is how people naturally write a
/// directory, and either may show up on Windows.
///
/// Trailing separators are how people naturally write directories, and either
/// separator may show up on Windows. A bare drive (`C:`) is *not* a path — the
/// stored parent is `C:\` — so the separator goes back on.
/// Empty out means "every folder", which is what a bare `/` or a blank value
/// comes to; [`build_filter`] turns that into `1=1`.
///
/// It used to special-case a bare drive (`C:` → `C:\`), because the filter's
/// `parent = ?` half had to match the stored spelling exactly. That half is
/// gone, and [`like_subtree_pattern`] puts the separator back itself, so both
/// spellings now produce the same pattern.
fn normalize_folder_value(value: &str) -> String {
let base = value.trim().trim_end_matches(['/', '\\']);
if base.len() == 2 && base.ends_with(':') && base.starts_with(|c: char| c.is_ascii_alphabetic())
{
return format!("{}{}", base, std::path::MAIN_SEPARATOR);
}
base.to_string()
value.trim().trim_end_matches(['/', '\\']).to_string()
}
/// A `LIKE ... ESCAPE '\'` pattern matching every path strictly beneath `dir`.
/// A `LIKE ... ESCAPE '\'` pattern matching `dir`'s own files and everything
/// beneath it.
///
/// The separator is escaped along with the base, because on Windows the
/// separator *is* the escape character — a hand-written `format!("{}/%", dir)`
@ -315,10 +314,15 @@ mod tests {
#[test]
fn path_filter_covers_the_folder_and_its_subtree() {
let f = frag("path", Op::Contains, "/home/me/docs");
assert!(f.sql.contains("f.parent = ?"));
assert!(f.sql.contains("f.parent LIKE ?"));
// The LIKE half must declare its escape character; without the clause
// a Windows separator would be eaten as an escape.
// One `LIKE`, and one bound value: since every stored parent ends in a
// separator, `dir + SEP + %` reaches the folder's own files as well as
// its subdirectories'. The `parent = ?` half this used to need — and
// the explicit collation that went with it — is gone.
assert!(f.sql.contains("f.parent LIKE ?"), "{}", f.sql);
assert!(!f.sql.contains("f.parent = ?"), "{}", f.sql);
assert_eq!(f.params.len(), 1);
// The LIKE must declare its escape character; without the clause a
// Windows separator would be eaten as an escape.
assert!(f.sql.contains("ESCAPE '\\'"), "{}", f.sql);
}
@ -404,12 +408,14 @@ mod tests {
.unwrap();
let base = format!("{}a{}b", root_prefix(), SEP);
// Stored parents always end in a separator, so each row here is spelled
// the way the indexer would spell it.
let rows = [
format!("{}{}sub", base, SEP), // inside
format!("{}{}sub{}deep", base, SEP, SEP), // deeper
base.clone(), // the folder itself
format!("{}a{}bc", root_prefix(), SEP), // prefix sibling: outside
format!("{}a", root_prefix()), // parent: outside
format!("{}{}sub{}", base, SEP, SEP), // inside
format!("{}{}sub{}deep{}", base, SEP, SEP, SEP), // deeper
format!("{}{}", base, SEP), // the folder's own files
format!("{}a{}bc{}", root_prefix(), SEP, SEP), // prefix sibling: outside
format!("{}a{}", root_prefix(), SEP), // parent: outside
];
for r in &rows {
conn.execute("INSERT INTO files (parent) VALUES (?1)", [r])
@ -423,7 +429,10 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(matched, 2, "only the two rows strictly beneath {}", base);
// Three, not two: the trailing separator is what brings the folder's
// *own* files in, which is why the filter needs no second predicate.
// The prefix sibling and the parent stay out.
assert_eq!(matched, 3, "the subtree of {}, and only that", base);
}
#[test]
@ -436,9 +445,9 @@ mod tests {
let base = format!("{}a_b", root_prefix());
for r in [
format!("{}{}inside", base, SEP), // real child
format!("{}axb{}bait", root_prefix(), SEP), // `_` must not glob to `x`
format!("{}100%_done{}x", root_prefix(), SEP),
format!("{}{}inside{}", base, SEP, SEP), // real child
format!("{}axb{}bait{}", root_prefix(), SEP, SEP), // `_` must not glob to `x`
format!("{}100%_done{}x{}", root_prefix(), SEP, SEP),
] {
conn.execute("INSERT INTO files (parent) VALUES (?1)", [&r])
.unwrap();
@ -456,13 +465,17 @@ mod tests {
#[test]
fn folder_value_normalization() {
use std::path::MAIN_SEPARATOR as SEP;
// Trailing separators of either flavour are stripped.
assert_eq!(normalize_folder_value("/home/me/"), "/home/me");
assert_eq!(normalize_folder_value(r"C:\Users\me\"), r"C:\Users\me");
// A bare drive is not a path; the stored parent is `C:\`.
assert_eq!(normalize_folder_value("C:"), format!("C:{}", SEP));
assert_eq!(normalize_folder_value(r"C:\"), format!("C:{}", SEP));
// A bare drive and a rooted one now normalize alike: the pattern
// builder puts the separator back either way.
assert_eq!(normalize_folder_value("C:"), "C:");
assert_eq!(normalize_folder_value(r"C:\"), "C:");
assert_eq!(
like_subtree_pattern(&normalize_folder_value("C:")),
like_subtree_pattern(&normalize_folder_value(r"C:\")),
);
// Empty means "everywhere".
assert_eq!(normalize_folder_value("/"), "");
assert_eq!(normalize_folder_value(" "), "");

View file

@ -5,8 +5,8 @@
//! produce. When the configuration changes, the two disagree — and almost
//! always in a way that can be *reconciled* rather than rebuilt:
//!
//! * A root was removed. Its rows are a contiguous `files.path` range, so
//! they go in five statements ([`crate::db::repo::delete_subtree`]).
//! * A root was removed. Its rows are a contiguous `files.parent` range, so
//! they go in four statements ([`crate::db::repo::delete_subtree`]).
//! * An ignore pattern was added, hidden files were switched off, symlinks
//! stopped being followed. The rows to drop are picked out by a predicate
//! no SQL range can express, so [`Scope::covers`] re-runs the walker's own
@ -51,7 +51,7 @@ use crate::indexing::ReconcileProgress;
/// through [`crate::db::InterruptGuard`].
pub const SLICE: Duration = Duration::from_millis(250);
/// One configured root, with the `files.path` range it owns precomputed.
/// One configured root, with the `files.parent` range it owns precomputed.
struct Root {
path: PathBuf,
lo: String,
@ -208,9 +208,9 @@ pub struct WorkCursor {
dropped_aliases: bool,
/// Index into `scope.roots` of the range being scanned.
root_idx: usize,
/// Last path served by the scan — the keyset cursor. Empty means "start
/// this root's range from its `lo` bound".
after: String,
/// Last `(parent, name)` served by the scan — the keyset cursor. An empty
/// parent means "start this root's range from its `lo` bound".
after: (String, String),
/// Set once the FTS automerge that follows a batch of deletions has run.
finalized: bool,
/// Rows deleted so far, for the log line when the work completes.
@ -233,7 +233,7 @@ impl WorkCursor {
drop_idx: 0,
dropped_aliases: false,
root_idx: 0,
after: String::new(),
after: (String::new(), String::new()),
finalized: false,
deleted: 0,
recontented: 0,
@ -352,16 +352,19 @@ pub fn advance(
return Ok(());
}
let root = &cursor.scope.roots[cursor.root_idx];
if cursor.after.is_empty() {
cursor.after = root.lo.clone();
if cursor.after.0.is_empty() {
// `(lo, "")` sorts below every row in the range: no stored name
// is empty, so the first page starts exactly at `lo`.
cursor.after = (root.lo.clone(), String::new());
}
let rows = repo::rows_in_range_page(conn, &cursor.after, &root.hi, page)?;
let rows =
repo::rows_in_range_page(conn, &cursor.after.0, &cursor.after.1, &root.hi, page)?;
let Some(last) = rows.last() else {
cursor.root_idx += 1;
cursor.after.clear();
cursor.after = (String::new(), String::new());
continue;
};
cursor.after = last.path.clone();
cursor.after = (last.parent.clone(), last.name.clone());
cursor.examined += rows.len();
let root = cursor.scope.roots[cursor.root_idx].path.clone();
let (deleted, recontented) = apply_page(

View file

@ -45,12 +45,12 @@ fn seed(conn: &mut Connection, paths: &[PathBuf]) {
let tx = conn.transaction().unwrap();
for path in paths {
let path = path.to_string_lossy();
let (parent, name) = path.rsplit_once('/').unwrap();
// The indexer's own split, so the separator stays with the parent.
let (parent, name) = crate::file_handling::split_db_path(&path).expect("a file's path");
repo::insert_file(
&tx,
&repo::NewFile {
name,
path: &path,
parent,
size: 1,
mtime: 1,

View file

@ -277,11 +277,14 @@ fn rank_order(a: &SearchHit, b: &SearchHit) -> std::cmp::Ordering {
}
/// The `files` columns every pass selects, in the order the passes index
/// them: `0` id, `1` name, `2` path, `3` size, `4` mtime. Passes that also
/// them: `0` id, `1` name, `2` parent, `3` size, `4` mtime. Passes that also
/// want the stored document text append `dt.text_zstd` as column `5`. A pass
/// spelling its own list in a different order would compile and then quietly
/// serve paths as names.
const HIT_COLUMNS: &str = "f.id, f.name, f.path, f.size, f.mtime";
/// serve parents as names.
///
/// There is no `path` column to select; [`Cx::scan_pass`] concatenates columns
/// 2 and 1 into one reused buffer and hands every classifier the result.
const HIT_COLUMNS: &str = "f.id, f.name, f.parent, f.size, f.mtime";
/// Columns 3 and 4. The clamp matters: `size` is `INTEGER` in SQLite and so
/// signed; a corrupt row holding `-1` would otherwise become 18 exabytes on

View file

@ -62,41 +62,72 @@ impl<'a> Cx<'a> {
let mut deferred = Deferred::default();
let mut scanned = 0usize;
let mut clock = FlushClock::new();
// Set when the loop stops on the display limit rather than on the end
// of the candidate set — rows were left unexamined, so the result set
// is cut whatever the final flush happens to hold.
let mut cut_short = false;
// The reassembled path for the row in hand. One buffer for the whole
// scan, refilled per row: `files` stores `parent` and `name` and no
// longer a third column holding their concatenation, so this is where
// the concatenation happens. Reused rather than allocated because it
// runs for every *scanned* row — a full-table scan on three of the
// passes — while only the few that become hits keep an owned copy.
//
// A local, not a field on `self`: the classifiers take `&mut Self`, and
// a buffer borrowed out of `self` could not be handed to them.
let mut path = String::new();
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
scanned += 1;
if scanned.is_multiple_of(cancel_every) && self.cancelled() {
return Ok(false);
}
// The display limit is already full, and holding a row proves at
// least one more match exists than will be shown — so `limited` is
// exactly true here, and everything below is work whose result
// `flush_pass` would throw away. That work is not small: the
// full-text passes decompress the document, fold a copy of it, and
// cut a snippet, per row. `cascade::run` makes the same test
// between passes; without this one a single pass over a common
// term runs to the end of the candidate set.
if self.remaining() == 0 {
self.limited = true;
break;
}
let file_id: i64 = col(row, 0)?;
// Borrowed from the statement rather than `col::<String>`: this
// runs for every *scanned* row — a full-table scan on the filename
// pass — while only the few that become hits need an owned copy.
let path = row
.get_ref(2)
let borrowed = |idx| -> Result<&str, String> {
row.get_ref(idx)
.map_err(|e| e.to_string())?
.as_str()
.map_err(|e| e.to_string())?;
if self.skip(file_id, path) {
.map_err(|e| e.to_string())
};
path.clear();
// Parent first, and no separator between them: every stored parent
// already ends in one. See `file_handling::dir_to_db_parent`.
path.push_str(borrowed(2)?);
path.push_str(borrowed(1)?);
if self.skip(file_id, &path) {
continue;
}
match classify(self, row, file_id, path)? {
match classify(self, row, file_id, &path)? {
RowHit::Skip => {}
RowHit::Emit(hit) => {
buf.push(hit);
overflowed |= self.enforce_cap(&mut buf);
self.flush_if_due(&mut buf, &mut clock);
// Stop once the display limit is full. Everything past
// this point is work `flush_pass` would throw away, and it
// is not small: the full-text passes decompress the
// document, fold a copy of it and cut a snippet, per row.
// `cascade::run` makes the same test between passes;
// without this one a single pass over a common term runs
// to the end of its candidate set.
//
// Tested *here* rather than at the top of the loop, where
// it is tempting: a scanned row is not a match. It may be
// one an earlier pass already emitted, or one `classify`
// rejects — the filename pass's SQL is deliberately a
// superset feeding both the name and the path tier — so
// stopping on one would end the pass over a row that was
// never going to be shown.
//
// The break is recorded rather than turned into `limited`
// on the spot. `flush_pass` decides that, because it knows
// how much it had to truncate; claiming it here would say
// a set was cut whenever it happened to end exactly at the
// limit. But leaving the scan *unfinished* silently is the
// opposite error — see the flush below.
if self.remaining() == 0 {
cut_short = true;
break;
}
}
RowHit::Defer(hit) => {
deferred.hits.push(hit);
@ -114,6 +145,15 @@ impl<'a> Cx<'a> {
None => debug_assert!(deferred.hits.is_empty(), "deferred hits with no slot"),
}
self.flush_pass(buf, overflowed);
// After the flush, so it cannot be undone by one that truncated
// nothing. A scan that broke on the limit left rows unexamined, and
// the two places that would otherwise notice both miss the boundary
// case: `flush_pass` compares `buf.len() > room`, which is `0 > 0`
// when a mid-scan flush landed exactly on the limit, and
// `cascade::run` tests `remaining()` at the top of the *next* pass,
// of which the last pass has none. Without this a cut result set can
// report itself complete.
self.limited |= cut_short;
Ok(true)
}
@ -123,26 +163,57 @@ impl<'a> Cx<'a> {
let query = self.query;
let pattern = &query.pattern;
let with_paths = path_tiers_enabled(pattern);
// A path always ends in its own name, so `path LIKE` is the
// superset that feeds both the name and the path tiers.
let sql = format!(
"SELECT {} FROM files f \
WHERE {} LIKE ? ESCAPE '\\'{}",
HIT_COLUMNS,
if with_paths { "f.path" } else { "f.name" },
query.filter_sql
);
// Wildcard patterns turn each star into an unescaped `%`; the
// substring wrap absorbs leading/trailing stars. User `%`/`_`
// remain escaped literals either way.
let like = pattern
let like = format!(
"%{}%",
pattern
.segments()
.iter()
.map(|s| escape_like(s))
.collect::<Vec<_>>()
.join("%");
let params =
self.params_with_filters(vec![rusqlite::types::Value::Text(format!("%{}%", like))]);
.join("%")
);
// There is no `path` column to `LIKE` against any more, and the
// prefilter has to stay a *superset* of what the classifier accepts or
// real hits vanish. Three cases:
//
// * Name tier only — unchanged, `name LIKE`.
// * Path tiers, and the pattern is a single segment containing no
// separator: `name LIKE ? OR parent LIKE ?` is then exactly
// equivalent to the old `path LIKE ?`. A match inside `parent || name`
// either sits wholly in one or straddles the boundary, and the
// boundary character is a separator the pattern does not contain. This
// is ordinary typing, so it is the case worth keeping cheap.
// * Anything else — a multi-segment wildcard whose `%` can span the
// boundary (`doc*q3` over `/x/docs/q3.txt`), or a term with a
// separator in it. No SQL predicate on one column covers those, so
// scan and let the classifier decide, exactly as passes C and E do.
let straddles = pattern.segments().len() > 1
|| pattern
.segments()
.iter()
.any(|s| s.contains(std::path::MAIN_SEPARATOR));
let (predicate, terms) = match (with_paths, straddles) {
(false, _) => (
"f.name LIKE ? ESCAPE '\\'".to_string(),
vec![rusqlite::types::Value::Text(like)],
),
(true, false) => (
"(f.name LIKE ? ESCAPE '\\' OR f.parent LIKE ? ESCAPE '\\')".to_string(),
vec![
rusqlite::types::Value::Text(like.clone()),
rusqlite::types::Value::Text(like),
],
),
(true, true) => ("1=1".to_string(), Vec::new()),
};
let sql = format!(
"SELECT {} FROM files f WHERE {}{}",
HIT_COLUMNS, predicate, query.filter_sql
);
let params = self.params_with_filters(terms);
self.scan_pass(
&sql,
params,

View file

@ -57,16 +57,25 @@ pub fn find_duplicate_groups(
}
}
// `ORDER BY parent, name` rather than by path: it is the stored key, so
// the sort is free, and it groups a directory's copies together — which is
// what the tab shows anyway.
let mut member_stmt = conn
.prepare("SELECT id, name, path, size, mtime FROM files WHERE hash = ?1 ORDER BY path")
.prepare(
"SELECT id, name, parent, size, mtime FROM files \
WHERE hash = ?1 ORDER BY parent, name",
)
.map_err(|e| e.to_string())?;
for group in &mut groups {
let rows = member_stmt
.query_map(params![group.hash], |r| {
let name: String = r.get(1)?;
let parent: String = r.get(2)?;
let path = format!("{}{}", parent, name);
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
name,
path,
r.get::<_, i64>(3)?.max(0) as u64,
r.get::<_, i64>(4)?,
))
@ -91,13 +100,12 @@ mod tests {
let p = crate::testutil::scratch_dir("dups").join("index.sqlite");
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let tx = conn.transaction().unwrap();
let add = |name: &str, path: &str, size: u64, hash: Option<&[u8]>| {
let add = |name: &str, size: u64, hash: Option<&[u8]>| {
insert_file(
&tx,
&NewFile {
name,
path,
parent: "/d",
parent: "/d/",
size,
mtime: 1_700_000_000,
mime: None,
@ -110,20 +118,20 @@ mod tests {
.expect("unique path");
};
// Triple group of small files: redundant = 10 × 2 = 20.
add("a1.txt", "/d/a1.txt", 10, Some(b"AAA"));
add("a2.txt", "/d/a2.txt", 10, Some(b"AAA"));
add("a3.txt", "/d/a3.txt", 10, Some(b"AAA"));
add("a1.txt", 10, Some(b"AAA"));
add("a2.txt", 10, Some(b"AAA"));
add("a3.txt", 10, Some(b"AAA"));
// Pair of large files: redundant = 100 × 1 = 100 — sorts first
// despite the smaller member count.
add("b1.txt", "/d/b1.txt", 100, Some(b"BBB"));
add("b2.txt", "/d/b2.txt", 100, Some(b"BBB"));
add("b1.txt", 100, Some(b"BBB"));
add("b2.txt", 100, Some(b"BBB"));
// Singletons and NULL hashes never appear.
add("c.txt", "/d/c.txt", 30, Some(b"CCC"));
add("n1.txt", "/d/n1.txt", 40, None);
add("n2.txt", "/d/n2.txt", 40, None);
add("c.txt", 30, Some(b"CCC"));
add("n1.txt", 40, None);
add("n2.txt", 40, None);
// Zero-size files are trivially identical — excluded outright.
add("z1.txt", "/d/z1.txt", 0, Some(b"ZZZ"));
add("z2.txt", "/d/z2.txt", 0, Some(b"ZZZ"));
add("z1.txt", 0, Some(b"ZZZ"));
add("z2.txt", 0, Some(b"ZZZ"));
tx.commit().unwrap();
drop(conn);
p

View file

@ -187,11 +187,34 @@ struct SearchRequest {
/// its statement, tagged with the generation that owns it.
type InFlight = Arc<Mutex<Option<(u64, rusqlite::InterruptHandle)>>>;
/// One-shot channel the worker answers a release request on.
type ReleaseAck = Arc<Mutex<Option<mpsc::Sender<()>>>>;
/// How long [`SearchService::release_connection`] waits for the worker to
/// answer. Long enough for it to finish a statement and drop the handle,
/// short enough that a wedged worker cannot hold up a rebuild.
const RELEASE_WAIT: Duration = Duration::from_secs(2);
/// What the worker thread accepts. A search is the overwhelming majority;
/// the release exists because dropping the held connection is something only
/// the worker can do, and it is parked in a 30-second `recv_timeout` where a
/// flag would not reach it.
enum WorkerMsg {
Search(SearchRequest),
/// Drop the held connection now, so whoever asked can delete or replace
/// the index file. On Windows an open handle makes that fail outright;
/// everywhere else it keeps the deleted inode's blocks pinned.
ReleaseConnection,
}
pub struct SearchService {
req_tx: mpsc::Sender<SearchRequest>,
req_tx: mpsc::Sender<WorkerMsg>,
latest_gen: Arc<AtomicU64>,
in_flight: InFlight,
db_path: Arc<Mutex<PathBuf>>,
/// Where the worker reports that it has let the connection go; see
/// [`SearchService::release_connection`].
release_ack: ReleaseAck,
handle: Option<JoinHandle<()>>,
}
@ -214,11 +237,12 @@ impl SearchService {
notify: Arc<dyn Fn() + Send + Sync>,
idle_release: Duration,
) -> (SearchService, mpsc::Receiver<SearchUpdate>) {
let (req_tx, req_rx) = mpsc::channel::<SearchRequest>();
let (req_tx, req_rx) = mpsc::channel::<WorkerMsg>();
let (update_tx, update_rx) = mpsc::channel::<SearchUpdate>();
let latest_gen = Arc::new(AtomicU64::new(0));
let in_flight: InFlight = Arc::new(Mutex::new(None));
let db_path = Arc::new(Mutex::new(db_path));
let release_ack: ReleaseAck = Arc::new(Mutex::new(None));
let worker = Worker {
req_rx,
@ -227,6 +251,7 @@ impl SearchService {
latest_gen: latest_gen.clone(),
in_flight: in_flight.clone(),
db_path: db_path.clone(),
release_ack: release_ack.clone(),
open: None,
idle_release,
};
@ -241,6 +266,7 @@ impl SearchService {
latest_gen,
in_flight,
db_path,
release_ack,
handle: Some(handle),
},
update_rx,
@ -254,14 +280,42 @@ impl SearchService {
// Interrupt before enqueueing: an idle worker can dequeue the new
// request and be mid-statement within microseconds.
self.interrupt_stale();
let _ = self.req_tx.send(SearchRequest {
let _ = self.req_tx.send(WorkerMsg::Search(SearchRequest {
generation,
input: input.to_string(),
options,
});
}));
generation
}
/// Drop the connection the worker is holding, and wait briefly for it.
///
/// The worker keeps its connection for [`IDLE_RELEASE`] after the last
/// query so a typing session runs against a warm page cache. That is the
/// right default and the wrong thing to be holding when the index file is
/// about to be deleted: on Windows the delete fails outright, and a
/// "Rebuild index" that silently did not rebuild is worse than a slow one.
///
/// Cancelled first, and not merely as a courtesy. The worker coalesces its
/// queue, so a search enqueued just before this — a keystroke, then a
/// click on *Rebuild index* — is still current when the release is found,
/// and the worker would acknowledge the release and then **reopen** the
/// connection to serve it. Bumping the generation makes that request stale,
/// so it is dropped at the generation check instead. Results against a file
/// about to be deleted are worth nothing anyway.
///
/// Best-effort past that — it returns once the worker acknowledges or the
/// wait elapses, and the caller proceeds either way.
pub fn release_connection(&self) {
self.cancel();
let (ack_tx, ack_rx) = mpsc::channel();
*crate::lock_ok(&self.release_ack) = Some(ack_tx);
if self.req_tx.send(WorkerMsg::ReleaseConnection).is_err() {
return;
}
let _ = ack_rx.recv_timeout(RELEASE_WAIT);
}
/// Cancel without starting anything new.
pub fn cancel(&self) {
self.latest_gen.fetch_add(1, Ordering::SeqCst);
@ -320,12 +374,13 @@ pub fn classify_sql_err(error_msg: &str) -> String {
}
struct Worker {
req_rx: mpsc::Receiver<SearchRequest>,
req_rx: mpsc::Receiver<WorkerMsg>,
update_tx: mpsc::Sender<SearchUpdate>,
notify: Arc<dyn Fn() + Send + Sync>,
latest_gen: Arc<AtomicU64>,
in_flight: InFlight,
db_path: Arc<Mutex<PathBuf>>,
release_ack: ReleaseAck,
/// The connection, and the index generation and path it was opened
/// against. See [`Worker::take_connection`].
open: Option<OpenIndex>,
@ -345,7 +400,11 @@ impl Worker {
fn run(mut self) {
loop {
let first = match self.req_rx.recv_timeout(self.idle_release) {
Ok(req) => req,
Ok(WorkerMsg::ReleaseConnection) => {
self.release();
continue;
}
Ok(WorkerMsg::Search(req)) => req,
Err(mpsc::RecvTimeoutError::Timeout) => {
// Dropping the connection frees `PRAGMAS_SEARCH`'s 32 MiB
// page cache to glibc, which parks it in an arena rather
@ -366,10 +425,19 @@ impl Worker {
Err(mpsc::RecvTimeoutError::Disconnected) => return,
};
// A fast typist queues several requests; only the newest one
// matters.
// matters. A release queued behind them is *not* superseded — it
// is the one message that must not be coalesced away, so it is
// handled where it is found. Whether the search below still runs
// is then the generation check's business:
// `release_connection` cancels before it sends, so anything
// enqueued ahead of the release is stale and drops out rather than
// reopening the connection that was just let go of.
let mut req = first;
while let Ok(newer) = self.req_rx.try_recv() {
req = newer;
match newer {
WorkerMsg::Search(newer) => req = newer,
WorkerMsg::ReleaseConnection => self.release(),
}
}
if req.generation != self.latest_gen.load(Ordering::SeqCst) {
continue;
@ -378,6 +446,19 @@ impl Worker {
}
}
/// Drop the held connection and tell whoever asked.
///
/// The same trim `run`'s idle path does, for the same reason: glibc parks
/// `PRAGMAS_SEARCH`'s page cache in an arena rather than returning it.
fn release(&mut self) {
if self.open.take().is_some() {
crate::platform::release_free_heap();
}
if let Some(ack) = crate::lock_ok(&self.release_ack).take() {
let _ = ack.send(());
}
}
/// Take the connection to run this request on, reopening if the one held
/// cannot be reused. Reuse is what keeps
/// [`crate::db::schema::PRAGMAS_SEARCH`]'s page cache warm across

View file

@ -121,6 +121,56 @@ pub fn touch(path: &std::path::Path, body: &[u8]) {
std::fs::write(path, body).expect("write file");
}
/// A filename that is legal on disk but cannot round-trip through the index,
/// spelled so that `to_string_lossy` yields exactly `{stem}\u{FFFD}{suffix}` —
/// which is itself a perfectly ordinary filename, and so a name a *different*
/// file can really have. That collision is what the screens in
/// `crate::walk::read_directory` and `crate::watcher` exist to prevent, and
/// pairing this with [`lossy_twin`] is how the tests reproduce it.
///
/// The two platforms fail in different ways and both are real:
///
/// * On Unix an `OsStr` is arbitrary bytes, so any invalid UTF-8 byte does it.
/// `0xFF` can never appear in well-formed UTF-8.
/// * On Windows a path is UTF-16 code units and NTFS does not check that they
/// are well-*formed*, so an unpaired surrogate is storable. Rust models this
/// with WTF-8, and `to_str()` returns `None` for precisely that case. Far
/// from theoretical: WSL's DrvFs encodes non-UTF-8 Linux names this way by
/// design, and Samba shares of Linux servers produce them from legacy
/// encodings.
///
/// Some filesystems (FAT, exFAT, some network redirectors) refuse the name —
/// tests that put one on disk must tolerate the creation failing rather than
/// asserting on it.
#[doc(hidden)]
pub fn unrepresentable_name(stem: &str, suffix: &str) -> std::ffi::OsString {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let mut bytes = stem.as_bytes().to_vec();
bytes.push(0xFF);
bytes.extend_from_slice(suffix.as_bytes());
std::ffi::OsString::from_vec(bytes)
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
let mut units: Vec<u16> = stem.encode_utf16().collect();
// A high surrogate with nothing after it to pair with.
units.push(0xD800);
units.extend(suffix.encode_utf16());
std::ffi::OsString::from_wide(&units)
}
}
/// The name [`unrepresentable_name`] collapses to under `to_string_lossy`, as
/// a name that is genuinely representable — so a test can put both on disk and
/// assert the real one survives what happens to the other.
#[doc(hidden)]
pub fn lossy_twin(stem: &str, suffix: &str) -> String {
format!("{}\u{FFFD}{}", stem, suffix)
}
/// Power-of-two bucket, so memory-map sizes group by what allocated them
/// rather than by their exact size. Shared by the memory probes.
#[doc(hidden)]
@ -162,6 +212,32 @@ mod tests {
assert_eq!(std::fs::read(&deep).unwrap(), b"hi");
}
/// The premise every collision test rests on, pinned per platform: the
/// name really is unrepresentable, and its lossy image really is a name
/// another file could have. If this ever stops holding, those tests would
/// silently start asserting nothing.
#[test]
fn the_unrepresentable_name_collapses_onto_its_twin() {
let bad = unrepresentable_name("x", ".txt");
assert!(
bad.to_str().is_none(),
"the name must not be representable: {:?}",
bad
);
assert_eq!(
bad.to_string_lossy(),
lossy_twin("x", ".txt"),
"the two names must collide under to_string_lossy"
);
assert!(
std::path::Path::new(&lossy_twin("x", ".txt"))
.as_os_str()
.to_str()
.is_some(),
"the twin must itself be a perfectly ordinary name"
);
}
/// The sweep runs against a shared temp directory, so what it matches is
/// the whole safety argument. `quicksearch-capture` is the one that would
/// hurt: `packaging/capture.sh` puts a run's screenshots and screencasts

View file

@ -145,6 +145,31 @@ pub fn decode_text(bytes: Vec<u8>, path: &Path) -> Result<String, String> {
}
}
/// Replace control characters with `U+FFFD`, borrowing when there are none.
///
/// Filenames may contain any byte but NUL and the separator, and extracted
/// text is whatever was in the file — so both can carry terminal escape
/// sequences. Printed raw they rewrite the line, retitle the window, or on a
/// terminal with OSC 52 enabled put text of the writer's choosing on the
/// user's clipboard. `ls` has scrubbed for this reason for decades.
///
/// Tab survives: it is a legitimate part of a filename and harmless. So does
/// everything above C1 — this is not a general sanitiser, and the point is to
/// stay byte-for-byte faithful wherever there is nothing dangerous to remove.
pub fn scrub_controls(s: &str) -> std::borrow::Cow<'_, str> {
fn dangerous(c: char) -> bool {
(c.is_control() && c != '\t') || ('\u{80}'..='\u{9f}').contains(&c)
}
if !s.chars().any(dangerous) {
return std::borrow::Cow::Borrowed(s);
}
std::borrow::Cow::Owned(
s.chars()
.map(|c| if dangerous(c) { '\u{fffd}' } else { c })
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -26,8 +26,8 @@ use sha2::{Digest, Sha256};
use crate::config::{Config, IgnoreSet};
use crate::extract::Registry;
use crate::file_handling::{
classify_by_mtime, classify_for_indexing, path_to_db_string, prepare_file_record,
warn_if_unrepresentable, DirRows, FileIndexAction, OwnedNewFile, UnreadableDirs,
classify_by_mtime, classify_for_indexing, dir_to_db_parent, path_to_db_string,
prepare_file_record, DirRows, FileIndexAction, OwnedNewFile, UnreadableDirs,
};
mod pool;
@ -53,7 +53,8 @@ const NETWORK_THREADS: usize = 16;
/// One file the walk found, with everything the DB writer needs.
#[derive(Debug)]
pub struct WalkedFile {
/// Canonical path, and the `files.path` key.
/// Canonical path. The row it keys is `(parent, name)`; see
/// [`crate::file_handling::split_db_path`].
pub path: String,
pub action: FileIndexAction,
/// `None` when there is nothing to write: unchanged, or the record could
@ -164,6 +165,13 @@ struct Ctx {
follow_symlinks: bool,
include_hidden: bool,
ignore: IgnoreSet,
/// The index's own files, which this walk must never so much as open.
/// See [`crate::file_handling::index_file_set`] for why opening one is
/// fatal rather than merely wasteful.
///
/// Precomputed rather than derived per entry: it is one canonicalize, and
/// the alternative is a syscall against every file in the tree.
index_files: HashSet<PathBuf>,
pruned: PruneCounts,
config: Config,
registry: Arc<Registry>,
@ -175,9 +183,16 @@ struct Ctx {
/// count is kept. Reset by [`reset_run_warnings`].
static UNREADABLE_WARNINGS: crate::log::Throttle = crate::log::Throttle::new(20);
/// Arm this module's per-run warning throttle.
/// The same, for names that cannot round-trip through the index. Throttled
/// because a share can hold thousands of them: one legacy-encoded directory on
/// a Samba mount, or a `\\wsl.localhost\` tree, and every entry under it is a
/// separate occurrence.
static UNREPRESENTABLE_WARNINGS: crate::log::Throttle = crate::log::Throttle::new(20);
/// Arm this module's per-run warning throttles.
pub fn reset_run_warnings() {
UNREADABLE_WARNINGS.reset();
UNREPRESENTABLE_WARNINGS.reset();
}
/// Read one directory, apply the hidden/ignore rules, and split the result:
@ -204,7 +219,19 @@ fn read_directory(
if UNREADABLE_WARNINGS.allow() {
crate::log_warn!("cannot read {}: {}", dir.display(), e);
}
// "Gone" is not "could not look", and only the second one is a
// reason to distrust the walk. A directory deleted while the walk
// was in flight — a build tree, a browser cache — is a fact about
// the filesystem: its rows *should* fall to the stale sweep, and
// recording it here would both spare them and cost this root its
// stored walk count, which is what makes every later run pay for
// a second `find | wc` traversal of the whole tree.
//
// The same distinction the coordinator draws in `verb_for`:
// `NotFound` is unambiguous, every other errno is not.
if e.kind() != std::io::ErrorKind::NotFound {
ctx.unreadable.record(dir.to_path_buf());
}
return Vec::new();
}
};
@ -231,13 +258,49 @@ fn read_directory(
};
let name = entry.file_name();
let name = name.to_string_lossy();
// **The screen for names the index cannot spell, and the only one.**
//
// `files.name` and `files.parent` are TEXT, so a name that is not valid
// UTF-8 has no representation there; `to_string_lossy` would give one,
// but it is many-to-one, and every use of a path in this walk is a
// database *key*. A lossy name collides with the real name of a
// different file — U+FFFD is an ordinary filename character — and the
// collision is not a cosmetic one: the lossy parent makes the
// prefetcher hand this directory another directory's rows, and the diff
// below then reports all of them stale. Screening here, before any
// string is built, is what keeps that from being possible at all.
//
// On Unix this is any invalid byte sequence; on Windows it is an
// unpaired UTF-16 surrogate, which NTFS stores happily and WSL's DrvFs
// emits by design for non-UTF-8 Linux names.
//
// A directory is pruned whole and at its root: the join below would
// carry the bad component into every path beneath it, so nothing under
// it could be indexed either way, and stopping here costs one warning
// instead of one per descendant.
//
// Leaving the entry out of `present` is safe, despite the rule above
// that every `continue` must be a genuine "not indexable". No stored
// row can carry a name that is not valid UTF-8, so this entry has no
// row to protect; and a row whose name happens to *equal* the lossy
// spelling belongs to some other, representable entry, which this same
// listing yields separately and which marks itself present.
let Some(name) = name.to_str() else {
if UNREPRESENTABLE_WARNINGS.allow() {
crate::log_warn!(
"Skipping {:?} (name is not valid UTF-8, so it cannot be stored, hashed \
or text-indexed)",
entry.path()
);
}
continue;
};
// The closure runs only on Windows, where `entry.metadata()` is free —
// the attributes came back with the directory read, and it reports the
// entry itself rather than a link target.
if !ctx.include_hidden {
if let Some(reason) =
crate::platform::entry_hidden_reason(&name, || entry.metadata().ok())
crate::platform::entry_hidden_reason(name, || entry.metadata().ok())
{
match reason {
crate::platform::HiddenReason::DotPrefix => {
@ -260,7 +323,7 @@ fn read_directory(
continue;
}
}
if ctx.ignore.matches_component(&name) {
if ctx.ignore.matches_component(name) {
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
continue;
}
@ -269,6 +332,20 @@ fn read_directory(
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
continue;
}
// The index's own database and sidecars. Not a user preference and
// not overridable, because hashing one is not a slow row, it is a
// process-wide cancellation of SQLite's locks on a file we are in the
// middle of writing — see `file_handling::index_file_set`.
//
// `continue` rather than a `WalkedFile::skipped`, deliberately: this
// leaves the name out of `present`, so any row an earlier run wrote
// for the index — before this pruning existed, or from a spell when
// `database_path` pointed elsewhere — falls to the stale sweep and is
// deleted. `skipped` would keep it forever.
if ctx.index_files.contains(&path) {
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
continue;
}
// `file_type` is the cached `d_type` from the directory read.
match entry.file_type() {
@ -291,7 +368,33 @@ fn read_directory(
// which full-path ignore patterns would never match and
// `seen_dirs` could not dedup against an overlapping root.
if let Ok(target) = path.canonicalize() {
// The link's own name passed the screen above; the target
// is a different path and gets its own. Without this, a
// link to an unrepresentable path would be resolved to its
// *lossy* spelling — a path naming some other file
// entirely, which would then be walked or indexed in its
// place.
if target.to_str().is_none() {
if UNREPRESENTABLE_WARNINGS.allow() {
crate::log_warn!(
"Skipping {} (its target {:?} is not valid UTF-8, so it cannot \
be stored, hashed or text-indexed)",
path.display(),
target
);
}
continue;
}
let target = PathBuf::from(path_to_db_string(&target));
// Again on the resolved target: the check above tested the
// link's own name, and a symlink pointing at the index
// would otherwise walk straight past it into an `open`.
// Harmless for a directory target — the set holds only
// files — which is why one check covers both arms.
if ctx.index_files.contains(&target) {
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
continue;
}
match fs::metadata(&target) {
Ok(m) if m.is_dir() => found.push(Found::Dir(target)),
// The target's row belongs to its own directory, so
@ -302,7 +405,7 @@ fn read_directory(
}
}
Ok(_) => {
present.insert(name.into_owned());
present.insert(name.to_string());
// `None` on Unix and on any reparse point: see
// `entry_cached_metadata`.
let cached = crate::platform::entry_cached_metadata(|| entry.metadata().ok());
@ -310,7 +413,7 @@ fn read_directory(
}
// Type unknown: mark it present so an existing row survives.
Err(_) => {
present.insert(name.into_owned());
present.insert(name.to_string());
}
}
}
@ -318,7 +421,7 @@ fn read_directory(
if !unreadable_entry {
// Rebuild each stored path the way `prepare` does, by joining onto
// the canonical directory, so separators and roots match the
// `files.path` spelling exactly.
// stored spelling exactly.
stale.extend(
rows.keys()
.filter(|name| !present.contains(name.as_str()))
@ -365,16 +468,23 @@ pub fn path_digest(path: &str) -> u128 {
/// see [`crate::platform::metadata_or_stat`].
fn prepare(file: PendingFile, known: Known<'_>, ctx: &Ctx) -> WalkedFile {
let PendingFile { path, cached } = file;
// Every route here has already screened the path: a root is screened after
// canonicalizing (which can resolve onto a name the config string was
// not), `Job::Files` comes from a listing `read_directory` filtered, and
// `Job::Alias` from a target the symlink arm checked. That
// matters because `path_to_db_string` is lossy, and the string below is
// used as a database key *and* hashed into the run's duplicate-visit set —
// a lossy one would key another file's row and could consume its digest,
// silently dropping it from the index.
debug_assert!(
path.to_str().is_some(),
"an unrepresentable path reached prepare(): {:?}",
path
);
let db_path = path_to_db_string(&path);
let digest = path_digest(&db_path);
let aliased = matches!(known, Known::Exact(_));
// A name that is not valid UTF-8 cannot be stored in `files.path`. Emitted
// as `Skip` because the caller reads a missing path as "deleted".
if warn_if_unrepresentable(&path) {
return WalkedFile::skipped(db_path, digest, aliased);
}
let Ok(meta) = crate::platform::metadata_or_stat(&path, cached) else {
// Seen but unreadable: a transient stat failure must not read as
// "deleted".
@ -391,11 +501,15 @@ fn prepare(file: PendingFile, known: Known<'_>, ctx: &Ctx) -> WalkedFile {
let action = match known {
Known::InDir(rows) => {
// `to_str`, not `to_string_lossy`: this name is looked up in the
// directory's stored rows, and the lossy spelling of one file is a
// valid name for another. The screen in `read_directory` is what
// makes it always `Some`.
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.and_then(|n| n.to_str())
.unwrap_or_default();
classify_for_indexing(&name, mtime, rows)
classify_for_indexing(name, mtime, rows)
}
Known::Exact(stored) => classify_by_mtime(stored, mtime),
};
@ -494,7 +608,7 @@ fn prefetcher(shared: &Shared, db_path: &str) {
while let Some(work) = shared.take_prefetch() {
match work {
PrefetchWork::Dir(dir) => {
match crate::db::repo::dir_rows(&conn, &path_to_db_string(&dir)) {
match crate::db::repo::dir_rows(&conn, &dir_to_db_parent(&dir)) {
Ok(rows) => shared.finish_prefetch(Job::Dir(dir, Arc::new(rows))),
Err(e) => {
crate::log_warn!("{}", e);
@ -551,7 +665,7 @@ impl ParallelWalk {
crate::lock_ok(&self.shared.queue)
.seen_dirs
.iter()
.map(|d| path_to_db_string(d))
.map(|d| dir_to_db_parent(d))
.collect()
}
@ -712,6 +826,22 @@ pub fn walk_indexable_files(
//
// Roots themselves are never filtered — the user chose them.
match fs::canonicalize(root) {
// A root string is UTF-8 by construction — it came from the config
// — but `canonicalize` resolves symlinks, so what it resolves *to*
// need not be: `~/docs` can be a link to a directory whose real
// name the index cannot spell. Stored lossily, the root would be
// walked under a parent string that names some other directory
// entirely. Treated exactly like a root that would not resolve at
// all, which is what it amounts to: yields nothing, and is recorded
// so stale cleanup does not read that as "everything was deleted".
Ok(dir) if dir.to_str().is_none() => {
crate::log_warn!(
"cannot index root {}: it resolves to {:?}, whose name is not valid UTF-8",
root,
dir
);
unresolvable.push(PathBuf::from(root));
}
Ok(dir) => {
let dir = PathBuf::from(path_to_db_string(&dir));
if queue.seen_dirs.insert(dir.clone()) {
@ -738,6 +868,7 @@ pub fn walk_indexable_files(
follow_symlinks,
include_hidden,
ignore,
index_files: crate::file_handling::index_file_set(Path::new(db_path)),
pruned: PruneCounts::default(),
config,
registry,

View file

@ -14,16 +14,13 @@ fn db_with(tag: &str, rows: &[(String, u64)]) -> PathBuf {
let p = crate::testutil::scratch_dir(tag).join("index.sqlite");
let conn = crate::db::open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
for (path, mtime) in rows {
let as_path = Path::new(path);
// Split the same way the indexer does, so the seeded parent carries
// its trailing separator and the prefetcher's `parent = ?` finds it.
let (parent, name) = crate::file_handling::split_db_path(path).expect("a file's path");
conn.execute(
"INSERT INTO files (name, path, parent, size, mtime, type, content_state)
VALUES (?1, ?2, ?3, 0, ?4, 0, 3)",
rusqlite::params![
as_path.file_name().unwrap().to_string_lossy(),
path,
as_path.parent().unwrap().to_string_lossy(),
*mtime as i64,
],
"INSERT INTO files (name, parent, size, mtime, type, content_state)
VALUES (?1, ?2, 0, ?3, 0, 3)",
rusqlite::params![name, parent, *mtime as i64],
)
.unwrap();
}
@ -108,35 +105,237 @@ fn walks_a_nested_tree_exactly_once() {
fs::remove_dir_all(&root).ok();
}
/// A name that is not valid UTF-8 must be skipped, not deleted. Unix only:
/// on Windows `OsString` comes from UTF-16 and the case cannot be built.
#[cfg(unix)]
#[test]
fn a_non_utf8_name_is_skipped_and_never_prepared() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
/// Put `name` on disk under `dir`, or report that this filesystem refused it.
///
/// FAT, exFAT and some network redirectors reject the names
/// [`crate::testutil::unrepresentable_name`] builds. A test that cannot create
/// one has nothing to assert and says so, rather than failing on the
/// filesystem's behalf.
fn try_touch(path: &Path) -> bool {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, b"x").is_ok() && path.symlink_metadata().is_ok()
}
fn try_mkdir(path: &Path) -> bool {
fs::create_dir_all(path).is_ok() && path.symlink_metadata().is_ok()
}
/// A name the index cannot spell is dropped at the directory entry, before any
/// string is built from it — so it is neither prepared nor reported.
///
/// Both platforms, and the Windows half is not the exotic one: `OsString`
/// there is WTF-8 over UTF-16, and `OsString::from_wide(&[0xD800])` builds an
/// unpaired surrogate that NTFS stores happily. (An older comment here claimed
/// the case could not be built on Windows. It can, which is exactly why WSL
/// and Samba trees hit it.)
#[test]
fn a_non_utf8_name_is_dropped_at_the_entry() {
let root = tmp_tree("nonutf8");
touch(&root.join("plain.txt"));
// 0xFF only survives `to_string_lossy` as U+FFFD.
let bad = root.join(OsStr::from_bytes(b"DRH257\xff~X.MP4"));
touch(&bad);
assert!(bad.symlink_metadata().is_ok(), "the file really is on disk");
let bad = root.join(crate::testutil::unrepresentable_name("DRH257", "~X.MP4"));
if !try_touch(&bad) {
eprintln!("skipped: this filesystem will not store an unrepresentable name");
return;
}
let files = walk(&root, &empty_db("nonutf8"));
// Both are yielded, so neither reads as deleted...
assert_eq!(files.len(), 2, "the bad name is still reported as seen");
// ...but only the representable one is prepared for insertion.
let prepared: Vec<&WalkedFile> = files.iter().filter(|f| f.record.is_some()).collect();
assert_eq!(prepared.len(), 1);
assert!(prepared[0].path.ends_with("plain.txt"));
let skipped = files.iter().find(|f| f.record.is_none()).unwrap();
assert!(matches!(skipped.action, FileIndexAction::Skip));
// Not yielded at all — a `WalkedFile` for it would carry the lossy path as
// its key and its duplicate-visit digest.
assert_eq!(names(&files), vec!["plain.txt"]);
assert!(
skipped.path.contains('\u{FFFD}'),
"stored spelling is the lossy one"
files.iter().all(|f| !f.path.contains('\u{FFFD}')),
"no lossy spelling may reach the writer: {:?}",
files.iter().map(|f| &f.path).collect::<Vec<_>>()
);
fs::remove_dir_all(&root).ok();
}
/// The safety argument for dropping the entry, pinned: a `continue` in
/// `read_directory` leaves the name out of `present`, and everything left out
/// of `present` is deleted.
///
/// It is safe because the dropped entry has no row of its own — an
/// unrepresentable name cannot be stored — and because the row that *is*
/// named its lossy spelling belongs to a different file, which the same
/// listing yields separately and which marks itself present. This test is what
/// says that second half still happens.
#[test]
fn dropping_a_bad_entry_deletes_nothing() {
let root = tmp_tree("nonutf8-stale");
let twin = crate::testutil::lossy_twin("DRH257", "~X.MP4");
touch(&root.join("plain.txt"));
touch(&root.join(&twin));
let bad = root.join(crate::testutil::unrepresentable_name("DRH257", "~X.MP4"));
if !try_touch(&bad) {
eprintln!("skipped: this filesystem will not store an unrepresentable name");
return;
}
// Seed both real files as already indexed, so anything the walk reports
// stale is a row it wants deleted.
let db = db_with(
"nonutf8-stale",
&[
(path_to_db_string(&root.join("plain.txt")), 0),
(path_to_db_string(&root.join(&twin)), 0),
],
);
let stale = stale_only(walk_indexable_files(
&[root.to_string_lossy().into_owned()],
false,
false,
IgnoreSet::compile(&[]).unwrap(),
db.to_str().unwrap(),
Config::default(),
Arc::new(Registry::default_set()),
Arc::new(AtomicBool::new(false)),
4,
));
assert!(
stale.is_empty(),
"both files are on disk; nothing may be deleted: {:?}",
stale
);
fs::remove_dir_all(&root).ok();
}
/// A root spelled in UTF-8 can still *resolve* onto a name that is not:
/// canonicalizing follows symlinks, so `~/docs` may be a link to a directory
/// the index cannot spell.
///
/// Stored lossily, that root's parent string names some other directory — so
/// the walk would attribute a completely unrelated tree to it, or read nothing
/// and report the root's real rows as deleted. It has to be recorded as
/// unreadable instead, which is what keeps stale cleanup off it.
#[cfg(unix)]
#[test]
fn a_root_that_resolves_onto_an_unrepresentable_name_is_not_walked() {
let base = tmp_tree("nonutf8-root");
let real = base.join(crate::testutil::unrepresentable_name("target", ""));
if !try_mkdir(&real) {
eprintln!("skipped: this filesystem will not store an unrepresentable name");
return;
}
touch(&real.join("inside.txt"));
// The root the user configures is an ordinary, spellable string.
let link = base.join("docs");
std::os::unix::fs::symlink(&real, &link).unwrap();
let root = link.to_str().expect("the configured root is spellable");
let mut walk = walk_indexable_files(
&[root.to_string()],
false,
false,
IgnoreSet::compile(&[]).unwrap(),
empty_db("nonutf8-root").to_str().unwrap(),
Config::default(),
Arc::new(Registry::default_set()),
Arc::new(AtomicBool::new(false)),
4,
);
let events: Vec<WalkEvent> = walk.by_ref().collect();
assert!(
events.is_empty(),
"nothing under an unspellable root may be walked: {:?}",
events
);
assert!(
!walk.unreadable().is_empty(),
"the root must read as unreadable, or its rows fall to stale cleanup"
);
std::fs::remove_dir_all(&base).ok();
}
/// The collision that made this worth fixing: a file whose *lossy* spelling is
/// another file's real name must not stand in for it.
///
/// Before the screen moved to the directory entry, the bad name was still
/// turned into a `WalkedFile` carrying the lossy path — and the pipeline hashes
/// that path into `seen_paths` before it looks for a record, so whichever of
/// the two the walk happened to reach second was dropped from the index.
#[test]
fn a_bad_name_cannot_stand_in_for_its_lossy_twin() {
let root = tmp_tree("nonutf8-twin");
let twin = crate::testutil::lossy_twin("x", ".txt");
touch(&root.join(&twin));
let bad = root.join(crate::testutil::unrepresentable_name("x", ".txt"));
if !try_touch(&bad) {
eprintln!("skipped: this filesystem will not store an unrepresentable name");
return;
}
let files = walk(&root, &empty_db("nonutf8-twin"));
let prepared: Vec<&WalkedFile> = files.iter().filter(|f| f.record.is_some()).collect();
assert_eq!(
prepared.len(),
1,
"exactly the representable file is prepared"
);
assert_eq!(
prepared[0].path,
path_to_db_string(&root.join(&twin)),
"and it is the real one, under its own name"
);
fs::remove_dir_all(&root).ok();
}
/// A directory the index cannot spell is pruned at its root: every path
/// beneath it would carry the bad component, so none of them is indexable
/// either way.
///
/// The reason this is the *directory* case and not just N file cases: the
/// walk's row prefetcher keys on the directory's lossy parent string, so a bad
/// directory sitting beside a real one with the colliding name was handed the
/// real one's rows — and then reported every one of them stale.
#[test]
fn a_bad_directory_is_pruned_with_its_whole_subtree() {
let root = tmp_tree("nonutf8-dir");
let twin = crate::testutil::lossy_twin("dir", "");
touch(&root.join(&twin).join("a.txt"));
let bad = root.join(crate::testutil::unrepresentable_name("dir", ""));
if !try_mkdir(&bad) {
eprintln!("skipped: this filesystem will not store an unrepresentable name");
return;
}
for n in 0..5 {
touch(&bad.join(format!("b{}.txt", n)));
}
touch(&bad.join("deep/c.txt"));
let db = db_with(
"nonutf8-dir",
&[(path_to_db_string(&root.join(&twin).join("a.txt")), 0)],
);
let stale = stale_only(walk_indexable_files(
&[root.to_string_lossy().into_owned()],
false,
false,
IgnoreSet::compile(&[]).unwrap(),
db.to_str().unwrap(),
Config::default(),
Arc::new(Registry::default_set()),
Arc::new(AtomicBool::new(false)),
4,
));
assert!(
stale.is_empty(),
"the real directory's row must survive its bad-named sibling: {:?}",
stale
);
let files = walk(&root, &db);
assert_eq!(
names(&files),
vec!["a.txt"],
"nothing under the bad directory is walked"
);
fs::remove_dir_all(&root).ok();
@ -804,3 +1003,97 @@ fn local_temp_dir_is_not_detected_as_network() {
);
fs::remove_dir_all(&root).ok();
}
/// **The index must never be walked into.**
///
/// Hashing a file means opening it, and on Unix closing a descriptor on an
/// inode cancels every advisory lock the whole process holds on it. Doing that
/// to `index.sqlite-shm` destroys the DMS lock SQLite took, after which an
/// attaching connection truncates the wal-index under our live mapping and the
/// next commit dies with SIGBUS — which is the crash this pruning exists to
/// prevent. The same close cancels the database's own locks, SQLite's
/// documented corruption hazard.
///
/// `include_hidden` is on because that is what exposes the default layout:
/// the index lives under `~/.local/share`, which a default walk skips for
/// being dot-named — so the hazard is real but latent until a user turns
/// hidden files on.
#[test]
fn the_index_and_its_sidecars_are_never_walked() {
let root = tmp_tree("walk-self-index");
touch(&root.join("ordinary.txt"));
// The index inside the tree being walked, as it is by default: the
// default root is the home directory and the default database sits
// beneath it.
let db = root.join("data").join("index.sqlite");
let conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
// A write, so the WAL and SHM exist to be walked over.
conn.execute(
"INSERT INTO files (name, parent, size, mtime, type, content_state)
VALUES ('a', '/', 0, 0, 0, 3)",
[],
)
.unwrap();
let found = names(&walk_with(&root, &db, false, true));
assert!(
found.contains(&"ordinary.txt".to_string()),
"the walk should still report ordinary files: {:?}",
found
);
for name in ["index.sqlite", "index.sqlite-wal", "index.sqlite-shm"] {
assert!(
!found.contains(&name.to_string()),
"{} was walked; found {:?}",
name,
found
);
}
drop(conn);
}
/// Pruning it is not enough — a row written before this existed has to go, or
/// the live watcher and duplicate verification keep opening the file from a
/// result list forever.
///
/// This is why the walk `continue`s past the index rather than emitting a
/// `WalkedFile::skipped`: a skip keeps the row, and only leaving the name out
/// of the directory's `present` set hands it to the stale sweep.
#[test]
fn a_stored_row_for_the_index_falls_to_the_stale_sweep() {
let root = crate::testutil::scratch_dir_canonical("walk-self-index-stale");
// The database the walk uses has to be the one inside the walked tree,
// or there is nothing self-referential to sweep.
let db = root.join("data").join("index.sqlite");
let conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
// The row an older build would have written for the database itself,
// inserted exactly the way `db_with` inserts one.
let stored = path_to_db_string(&db);
let (parent, name) = crate::file_handling::split_db_path(&stored).expect("a file's path");
conn.execute(
"INSERT INTO files (name, parent, size, mtime, type, content_state)
VALUES (?1, ?2, 0, 0, 0, 3)",
rusqlite::params![name, parent],
)
.unwrap();
drop(conn);
let stale = stale_only(walk_indexable_files(
&[root.to_string_lossy().into_owned()],
false,
true,
IgnoreSet::compile(&[]).unwrap(),
db.to_str().unwrap(),
Config::default(),
Arc::new(Registry::default_set()),
Arc::new(AtomicBool::new(false)),
4,
));
assert!(
stale.contains(&stored),
"the index's own row should be swept; got {:?}",
stale
);
}

View file

@ -30,7 +30,7 @@
//! root looks live while going silently stale. A single directory the kernel
//! refuses is logged and skipped instead (see [`add_watch`]).
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{mpsc, Arc, Mutex};
@ -97,6 +97,11 @@ pub enum WatchError {
KernelLimit {
registered: usize,
},
/// The kernel's event queue overflowed and events were dropped. Unlike
/// the two above this says nothing about the watcher's *capacity* — it
/// keeps working — only that the index is now out of step with the disk
/// by an unknown amount, so a full run is owed.
Overflowed,
Other(String),
}
@ -122,6 +127,11 @@ impl std::fmt::Display for WatchError {
""
}
),
WatchError::Overflowed => write!(
f,
"the system event queue overflowed and changes were missed; \
reindexing to catch up"
),
WatchError::Other(msg) => write!(f, "{}", msg),
}
}
@ -156,8 +166,9 @@ pub struct WatcherConfig {
/// How often the tick loop inspects the throttle map. Short ticks mean
/// low latency for first-in-a-burst; long ticks lower CPU at idle.
pub tick_interval: Duration,
/// Maximum directories processed per tick. Caps the time spent in a
/// single flush pass so long backlogs don't monopolize the thread.
/// Maximum directories *selected* per tick. A ceiling on the work one
/// pass takes on; [`FLUSH_BUDGET`] is what bounds how long it may spend
/// on them, and is the real limit.
pub max_dirs_per_tick: usize,
/// When to garbage-collect stale throttle entries (idle > window * N).
pub prune_max_age_multiplier: u32,
@ -172,7 +183,7 @@ impl Default for WatcherConfig {
pending_max_defer: Duration::from_secs(30),
throttle_window: Duration::from_secs(30),
tick_interval: Duration::from_millis(500),
max_dirs_per_tick: 64,
max_dirs_per_tick: 512,
prune_max_age_multiplier: 10,
max_watched_dirs: DEFAULT_MAX_WATCHED_DIRS,
}
@ -187,10 +198,23 @@ impl Default for WatcherConfig {
/// never blocks behind a large subtree registration.
struct WatchRegistry {
raw: RecommendedWatcher,
dirs: HashSet<PathBuf>,
/// Ordered, not hashed, so [`WatchRegistry::remove_tree`] can take the
/// subtree as a range instead of scanning every watched directory. The
/// set reaches `max_watched_dirs` (128k by default) and `rm -rf` deletes
/// bottom-up, so every directory in a deleted tree hits that path.
dirs: BTreeSet<PathBuf>,
cap: usize,
}
/// How long one flush pass may spend handing events to the sink.
///
/// Paired with `max_dirs_per_tick`: the count decides how many directories a
/// pass takes on, this decides when it stops regardless. A backlog then
/// drains at whatever the machine can actually do rather than at a fixed
/// directories-per-second, while one enormous directory still cannot hold the
/// tick loop.
const FLUSH_BUDGET: Duration = Duration::from_millis(50);
/// The mode every `watch()` call uses on this platform. See
/// [`crate::platform::WATCH_ROOTS_RECURSIVELY`] for why it differs.
const WATCH_MODE: RecursiveMode = if crate::platform::WATCH_ROOTS_RECURSIVELY {
@ -243,10 +267,15 @@ impl WatchRegistry {
if !self.dirs.contains(dir) {
return 0;
}
// A range from `dir`, stopping at the first entry that is no longer
// beneath it: descendants sort immediately after their ancestor, so
// this visits the subtree and one entry more, rather than the whole
// set. `starts_with` is still the test — it compares whole components,
// where a raw string prefix would take `/a/bc` for a child of `/a/b`.
let doomed: Vec<PathBuf> = self
.dirs
.iter()
.filter(|d| d.starts_with(dir))
.range(dir.to_path_buf()..)
.take_while(|d| d.starts_with(dir))
.cloned()
.collect();
for d in &doomed {
@ -311,6 +340,21 @@ impl Watcher {
let raw = RecommendedWatcher::new(
move |res: notify::Result<NotifyEvent>| match res {
Ok(ev) => {
// An overflow of the kernel's own event queue arrives here
// — on the *Ok* arm, as `EventKind::Other` with the rescan
// flag and no paths at all — so the error arm below never
// sees it and the per-path loop downstream iterates zero
// times. Left alone it is silent data loss: an arbitrary
// set of creates, modifies and removes never reaches the
// index while the watcher goes on reporting itself
// healthy. It is reported rather than repaired here
// because the events are simply gone; only a full run can
// find out what happened.
if ev.need_rescan() {
let mut slot = crate::lock_ok(&degraded_cb);
slot.get_or_insert(WatchError::Overflowed);
return;
}
// A closed receiver just means the watcher was stopped; ignore.
let _ = tx.send(ev);
}
@ -330,7 +374,7 @@ impl Watcher {
let registry = Arc::new(Mutex::new(WatchRegistry {
raw,
dirs: HashSet::new(),
dirs: BTreeSet::new(),
cap: config.max_watched_dirs,
}));
@ -416,6 +460,19 @@ impl Watcher {
crate::lock_ok(&self.degraded).is_some()
}
/// Forget the recorded reason, so a later one can take its place.
///
/// For [`WatchError::Overflowed`] only, and the distinction is the whole
/// point of the method: the other two reasons are *standing* — the watch
/// budget does not come back — while an overflow is a one-shot "you missed
/// some" from a watcher that is still delivering. Left in place it would
/// re-trigger on every coordinator tick and, worse, mask a real
/// [`WatchError::KernelLimit`] arriving afterwards, because the callback
/// records with `get_or_insert`.
pub fn clear_degraded(&self) {
*crate::lock_ok(&self.degraded) = None;
}
/// Signal the background thread to stop and wait for it to join. Safe to
/// call multiple times.
pub fn stop(&mut self) {
@ -594,6 +651,16 @@ fn unwatch_tree(ctx: &LoopCtx, path: &Path) {
/// on which subtrees exist. Under a recursive root watch it is the *only*
/// thing keeping `node_modules` churn out of the throttle map.
fn is_event_interesting(ctx: &LoopCtx, path: &Path) -> bool {
// A path the index cannot spell, screened here because this is the one
// gate every `FsEvent` passes through. Such a file is never indexed, so
// there is no row for a Create to update and none for a Remove to delete —
// but the incremental side keys on `path_to_db_string`, which is lossy, so
// letting the event through means acting on whichever *different* file
// happens to own the lossy spelling. The event carries no information and
// every use of it is a mistake.
if path.to_str().is_none() {
return false;
}
if ctx.filters.ignore.matches_path(path) {
return false;
}
@ -696,7 +763,18 @@ fn flush_ready(
}
}
}
// The count above bounds how many directories are *selected*; this bounds
// how long draining them may take, which is the thing that actually
// matters. Directory queues differ by orders of magnitude, so a fixed
// count is either too small after a large delete — at 64 per 500 ms tick
// the drain rate is 128 directories a second regardless of backlog, and a
// 20k-directory unpack takes minutes to reach the index — or too large for
// one deep directory.
let deadline = now + FLUSH_BUDGET;
for dir in ready {
if Instant::now() >= deadline {
break;
}
if let Some(entry) = throttle.get_mut(&dir) {
let drained: Vec<(PathBuf, QueuedOp)> = entry.queue.drain().collect();
entry.immediate = false;

View file

@ -189,6 +189,75 @@ fn e2e_create_modify_remove_surfaces() {
std::fs::remove_dir_all(&dir).ok();
}
/// A file the index cannot spell must produce no event at all.
///
/// It is never indexed, so no row exists for a `Create` to update or a
/// `Remove` to delete — but the incremental side keys on `path_to_db_string`,
/// which is lossy, so an event that got through would be applied to whichever
/// *different* file owns the lossy spelling. Screened in
/// [`is_event_interesting`], the one gate every `FsEvent` passes through.
///
/// A real file is created alongside, so a run where the watcher simply saw
/// nothing cannot pass by accident.
#[test]
fn events_for_an_unrepresentable_name_never_surface() {
let dir = tmp_dir("e2e-unrepresentable");
let bad = dir.join(crate::testutil::unrepresentable_name("report", ".txt"));
if std::fs::write(&bad, "hi").is_err() {
eprintln!("skipped: this filesystem will not store an unrepresentable name");
std::fs::remove_dir_all(&dir).ok();
return;
}
std::fs::remove_file(&bad).unwrap();
let (sink, got) = sink_to_vec();
let mut w = Watcher::start(
std::iter::once(&dir),
default_filters(),
fast_config(),
sink,
)
.unwrap();
std::fs::write(&bad, "hi").unwrap();
std::thread::sleep(Duration::from_millis(150));
std::fs::remove_file(&bad).unwrap();
// The control: whatever the backend does for the bad name, it certainly
// reports this one, so an empty event list means the watcher was working.
let good = dir.join("ordinary.txt");
std::fs::write(&good, "hi").unwrap();
std::thread::sleep(Duration::from_millis(250));
w.stop();
let events = got.lock().unwrap().clone();
assert!(
events.iter().any(|e| matches!(
e,
FsEvent::Create(p) | FsEvent::Modify(p) if p == &good
)),
"the control file produced no event, so this test proves nothing: {:?}",
events
);
let leaked: Vec<&FsEvent> = events
.iter()
.filter(|e| {
let paths: Vec<&PathBuf> = match e {
FsEvent::Create(p) | FsEvent::Modify(p) | FsEvent::Remove(p) => vec![p],
FsEvent::Rename { from, to } => vec![from, to],
};
paths.iter().any(|p| p.to_str().is_none())
})
.collect();
assert!(
leaked.is_empty(),
"an unrepresentable path reached the sink: {:?}",
leaked
);
std::fs::remove_dir_all(&dir).ok();
}
/// Descriptors are not spent on directories the indexer would discard.
#[test]
fn ignored_and_hidden_dirs_are_not_registered() {

View file

@ -31,15 +31,18 @@ impl Seeder {
}
/// Insert a file; `text: Some(..)` also content-indexes it.
///
/// `dir` is spelled without a trailing separator, as a caller naturally
/// would; the stored parent always carries one, because that is what makes
/// the path `parent || name` (see `file_handling::dir_to_db_parent`).
fn add(&mut self, name: &str, dir: &str, mtime: u64, text: Option<&str>) -> i64 {
let path = format!("{}/{}", dir, name);
let parent = format!("{}/", dir);
let tx = self.conn.transaction().unwrap();
let id = insert_file(
&tx,
&NewFile {
name,
path: &path,
parent: dir,
parent: &parent,
size: 42,
mtime,
mime: Some("text/plain"),
@ -561,6 +564,45 @@ fn limit_truncates_and_flags() {
std::fs::remove_file(&p).ok();
}
/// Stopping at the limit must not cost a result that belongs in it.
///
/// The scan breaks out as soon as the display limit is full, which is what
/// keeps a common term from decompressing its whole candidate set. The break
/// sits after classification for a reason: a *scanned* row is not a match —
/// the filename pass's SQL is a superset feeding two rank tiers — so breaking
/// on one would end the pass early over a row that was never going to be
/// shown, and the hits below it would be lost.
#[test]
fn stopping_at_the_limit_keeps_the_best_ranked_hits() {
let p = tmp_db("limit-break");
let mut s = Seeder::new(&p, true);
// Exact-name matches rank above substring matches, and are seeded last so
// that a pass which stopped too early would miss them.
for i in 0..20 {
s.add(&format!("zz-match-{:02}.txt", i), "/d", 1, None);
}
s.add("match", "/d", 1, None);
let conn = s.done();
let options = SearchOptions {
limit: 2,
..SearchOptions::default()
};
let (hits, outcome) = run_collect(&conn, "match", &options);
assert_eq!(hits.len(), 2);
assert!(
outcome.limited,
"21 matches under a limit of 2 is a cut set"
);
assert_eq!(
hits[0].name, "match",
"the exact match must survive the break"
);
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn session_ignores_hide_hits_before_the_cap() {
let p = tmp_db("ignores");
@ -1318,12 +1360,60 @@ fn the_connection_is_released_once_searching_stops() {
std::fs::remove_file(&p).ok();
}
/// An explicit release must actually leave the file closed — including when a
/// search was queued a moment before it.
///
/// The worker coalesces its request queue, so a release found behind a search
/// is handled where it sits and the search then runs. Without a cancel it
/// would run *after* the acknowledgement, reopening the index while the caller
/// — `Backend::rebuild_index` — is on its way to delete it. `release_connection`
/// therefore bumps the generation first, which is what this asserts: the
/// request made before the release is stale afterwards, so the worker drops it
/// instead of serving it.
///
/// The generation is checked rather than the descriptor because the race is
/// won or lost in microseconds; an fd assertion here would pass with the bug
/// in place most of the time and fail the rest, which is worse than no test.
#[test]
fn a_release_supersedes_a_search_queued_before_it() {
let p = tmp_db("relcancel");
let mut s = Seeder::new(&p, true);
s.add("held.txt", "/d", 1, Some("shared body"));
drop(s.done());
let (service, updates) = SearchService::new_with_idle_release(
p.clone(),
Arc::new(|| {}),
std::time::Duration::from_secs(60),
);
let queued = service.search("shared", SearchOptions::default());
service.release_connection();
let after = service.search("shared", SearchOptions::default());
assert!(
after > queued + 1,
"the release must advance the generation past {}, got {}",
queued,
after
);
// The service still works: cancelling is how the release is made safe, not
// a way of shutting the worker down.
assert_eq!(
search_names(&service, &updates, "shared").unwrap(),
vec!["held.txt"]
);
service.shutdown();
std::fs::remove_file(&p).ok();
}
/// The point of the whole change: a pass hands hits over *while* it scans, so
/// the UI has something to show long before the scan ends.
///
/// Proven by ordering rather than by batch count — `flush_pass` has always
/// chunked its output, so counting sink calls proves nothing. Pass A scans in
/// `files.path` order, so seeding a *worse* match at an early path and a
/// stored `(parent, name)` order, so seeding a *worse* match early and a
/// *better* one at a late path separates the two designs: emitting at the end
/// sorts them and leads with rank 1, while streaming hands over the rank-3 hit
/// before the scan has even reached the rank-1 one.
@ -1568,3 +1658,87 @@ fn a_fuzzy_mark_is_not_truncated_to_a_leading_part_of_the_term() {
&snip.window[a..b]
);
}
/// A path-tier match that straddles the directory/name boundary must still be
/// found.
///
/// `files` stores `parent` and `name` and no concatenation of the two, so the
/// filename pass cannot prefilter with a single `path LIKE`. For a one-piece
/// term it uses `name LIKE ? OR parent LIKE ?`, which is exactly equivalent —
/// a term with no separator in it cannot span the boundary. A multi-segment
/// wildcard *can*: `doc*q3` matches `/x/docs/q3.txt` with `doc` in the parent
/// and `q3` in the name, and neither half of that OR would fire. Those fall
/// back to scanning and letting the classifier decide, and this is the fixture
/// that fails if that fallback is ever "optimised" away.
#[test]
fn a_wildcard_spanning_the_directory_boundary_is_still_found() {
let p = tmp_db("pathstraddle");
let mut s = Seeder::new(&p, true);
let straddling = s.add("q3.txt", "/x/docs", 1, None);
// Same two pieces, both inside the name: found either way, so it proves
// the query ran rather than that the fallback was reached.
let in_name = s.add("doc-q3.txt", "/other", 2, None);
let _miss = s.add("q3.txt", "/x/plans", 3, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "doc*q3", &SearchOptions::default());
let mut ids: Vec<i64> = hits.iter().map(|h| h.file_id).collect();
ids.sort();
let mut want = vec![straddling, in_name];
want.sort();
assert_eq!(ids, want, "the straddling path match must not be dropped");
// And it is a path-tier hit carrying the whole path as its snippet.
let hit = hits.iter().find(|h| h.file_id == straddling).unwrap();
assert_eq!(hit.stage, 9);
assert_eq!(hit.path, "/x/docs/q3.txt", "parent and name rejoined");
drop(conn);
std::fs::remove_file(&p).ok();
}
/// The one-piece prefilter's other half: a term that is a whole directory
/// component matches through `parent LIKE`, with nothing in the name.
#[test]
fn a_term_matching_only_the_directory_is_found_through_the_parent() {
let p = tmp_db("pathparent");
let mut s = Seeder::new(&p, true);
let dir_hit = s.add("z.bin", "/srv/invoices", 1, None);
let _miss = s.add("z.bin", "/srv/other", 2, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "invoices", &SearchOptions::default());
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].file_id, dir_hit);
assert_eq!(hits[0].stage, 9);
drop(conn);
std::fs::remove_file(&p).ok();
}
/// `folder:` must reach the folder's own files, not just its subdirectories'.
///
/// The filter is a single `parent LIKE 'dir/%'`, which covers both only
/// because every stored parent ends in a separator — the folder's own files
/// have parent `dir/`, and `%` matches nothing. It used to need a second
/// `parent = ?` term to catch them.
#[test]
fn the_folder_filter_covers_the_folder_itself_and_its_subtree() {
let p = tmp_db("folderself");
let mut s = Seeder::new(&p, true);
let own = s.add("top.txt", "/srv/data", 1, None);
let nested = s.add("deep.txt", "/srv/data/2024", 2, None);
let _sibling = s.add("other.txt", "/srv/data-archive", 3, None);
let _outside = s.add("far.txt", "/srv", 4, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "txt folder:/srv/data", &SearchOptions::default());
let mut ids: Vec<i64> = hits.iter().map(|h| h.file_id).collect();
ids.sort();
let mut want = vec![own, nested];
want.sort();
assert_eq!(ids, want, "the folder's own files count as inside it");
drop(conn);
std::fs::remove_file(&p).ok();
}

View file

@ -91,12 +91,12 @@ fn encrypted_index_lifecycle() {
{
let conn = db::open_existing(&db_path.to_string_lossy(), true).unwrap();
conn.execute_batch(
"INSERT INTO files (name, path, parent, size, mtime, type, content_state)
"INSERT INTO files (name, parent, size, mtime, type, content_state)
WITH RECURSIVE n(i) AS (
SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 20000
)
SELECT 'p' || i, '/pad/' || i, '/pad', 0, 0, 0, 3 FROM n;
DELETE FROM files WHERE parent = '/pad';",
SELECT 'p' || i, '/pad/', 0, 0, 0, 3 FROM n;
DELETE FROM files WHERE parent = '/pad/';",
)
.unwrap();
drop(conn);

View file

@ -47,7 +47,7 @@ fn index_once(root: &Path, db: &Path, config: &Config) {
fn rows(db: &Path) -> Vec<(String, i64, i64)> {
let conn = rusqlite::Connection::open(db).unwrap();
let mut stmt = conn
.prepare("SELECT path, mtime, content_state FROM files ORDER BY path")
.prepare("SELECT parent || name, mtime, content_state FROM files ORDER BY parent, name")
.unwrap();
let out = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
@ -756,11 +756,11 @@ fn content_rows(db: &Path) -> Vec<ContentRow> {
let conn = rusqlite::Connection::open(db).unwrap();
let mut stmt = conn
.prepare(
"SELECT f.path, f.content_state, ff.reason, LENGTH(d.text_zstd)
"SELECT f.parent || f.name, f.content_state, ff.reason, LENGTH(d.text_zstd)
FROM files f
LEFT JOIN documents_text d ON d.file_id = f.id
LEFT JOIN failed_files ff ON ff.file_id = f.id
ORDER BY f.path",
ORDER BY f.parent, f.name",
)
.unwrap();
let out = stmt
@ -778,7 +778,7 @@ fn stored_text(db: &Path, suffix: &str) -> Option<String> {
.query_row(
"SELECT d.text_zstd FROM documents_text d
JOIN files f ON f.id = d.file_id
WHERE f.path LIKE '%' || ?1",
WHERE f.parent || f.name LIKE '%' || ?1",
[suffix],
|r| r.get(0),
)
@ -900,7 +900,7 @@ fn undecodable_small_files_are_reported_as_failures_not_silently_skipped() {
.query_row(
"SELECT f.content_state, ff.reason FROM files f \
LEFT JOIN failed_files ff ON ff.file_id = f.id \
WHERE f.path LIKE '%bad.txt'",
WHERE f.parent || f.name LIKE '%bad.txt'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
@ -943,7 +943,7 @@ fn an_unreadable_legacy_office_file_fails_with_a_reason() {
.query_row(
"SELECT f.content_state, ff.reason FROM files f \
LEFT JOIN failed_files ff ON ff.file_id = f.id \
WHERE f.path LIKE '%broken.doc'",
WHERE f.parent || f.name LIKE '%broken.doc'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
@ -981,7 +981,7 @@ fn extensionless_text_files_are_indexed() {
let conn = rusqlite::Connection::open(&db).unwrap();
let state_of = |name: &str| -> i64 {
conn.query_row(
"SELECT content_state FROM files WHERE path LIKE '%' || ?1",
"SELECT content_state FROM files WHERE parent || name LIKE '%' || ?1",
[name],
|r| r.get(0),
)
@ -1162,7 +1162,7 @@ fn an_empty_file_is_done_with_no_snippet_sidecar() {
let (state, sidecars): (i64, i64) = conn
.query_row(
"SELECT f.content_state, (SELECT COUNT(*) FROM documents_text d WHERE d.file_id = f.id)
FROM files f WHERE f.path LIKE '%empty.txt'",
FROM files f WHERE f.parent || f.name LIKE '%empty.txt'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
@ -1188,7 +1188,7 @@ fn the_content_extension_filter_still_excludes_small_text_files() {
let conn = rusqlite::Connection::open(&db).unwrap();
let states: Vec<(String, i64)> = conn
.prepare("SELECT path, content_state FROM files ORDER BY path")
.prepare("SELECT parent || name, content_state FROM files ORDER BY parent, name")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
@ -1933,7 +1933,7 @@ fn high_byte_binaries_are_listed_but_not_text_extracted() {
"SELECT f.content_state,
(SELECT COUNT(*) FROM documents_text d WHERE d.file_id = f.id),
(SELECT COUNT(*) FROM failed_files x WHERE x.file_id = f.id)
FROM files f WHERE f.path LIKE '%' || ?1",
FROM files f WHERE f.parent || f.name LIKE '%' || ?1",
[suffix],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)

View file

@ -62,14 +62,22 @@ fn conn(db: &Path) -> rusqlite::Connection {
rusqlite::Connection::open(db).unwrap()
}
/// Every indexed path, in **path** order.
///
/// Sorted here rather than in SQL: the index's own order is `(parent, name)`,
/// which groups by directory and is not the same sequence — `/r/keep.txt`
/// sorts *before* `/r/sub/k2.txt` there and after it by path. These assertions
/// are about which rows survived, so they read best in the order a person
/// would list them.
fn paths(db: &Path) -> Vec<String> {
let c = conn(db);
let mut stmt = c.prepare("SELECT path FROM files ORDER BY path").unwrap();
let out = stmt
let mut stmt = c.prepare("SELECT parent || name FROM files").unwrap();
let mut out: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
out.sort();
out
}

View file

@ -114,13 +114,12 @@ fn seed(path: &std::path::Path) {
let w1 = WORDS[(rng.next() as usize) % WORDS.len()];
let w2 = WORDS[(rng.next() as usize) % WORDS.len()];
let name = format!("{}-{}-{:07}.txt", w1, w2, i);
let dir = format!("/seed/{:03}", i % 500);
let full = format!("{}/{}", dir, name);
// Stored parents always end in a separator.
let dir = format!("/seed/{:03}/", i % 500);
let id = insert_file(
&tx,
&NewFile {
name: &name,
path: &full,
parent: &dir,
size: 4096,
mtime: 1_700_000_000 + i as u64,

View file

@ -11,6 +11,7 @@ use quicksearch_core::db;
use quicksearch_core::indexing::{
overall_progress, ConfigChange, IndexingStatus, PrepStep, RootPhase, RootProgress,
};
use quicksearch_core::platform::{IndexLock, LockError};
use quicksearch_core::search::SearchOptions;
use quicksearch_core::security::{derive_key, generate_salt, salt_to_hex, IndexKey};
use quicksearch_core::watcher::WatchError;
@ -322,10 +323,45 @@ impl QuickSearchApp {
.watch_cap_warned_roots
.retain(|root| new.paths.indexing_paths.contains(root));
let actions = diff_actions(&self.cfg, &new);
// The instance lock follows the database path, and it has to move
// before the change is written: a `database_path` that another
// instance already holds must never reach the config file, or the next
// launch reads it and refuses to start at all. `move_to` takes the new
// lock before dropping the old, so this rejection leaves us holding
// what we already had.
if actions.search_db_changed {
match IndexLock::move_to(&new.resolved_database_path()) {
Ok(()) => {}
Err(LockError::Held { pid }) => {
let who = match pid {
Some(pid) => format!(" (process {})", pid),
None => String::new(),
};
self.config_error = Some(format!(
"Not applied: another QuickSearch{} is using that index.",
who
));
return false;
}
// The move happened and the new path simply cannot be locked.
// Same rule as at startup: a convenience guard is never a good
// enough reason to refuse.
Err(LockError::Unsupported(why)) => {
quicksearch_core::log_warn!("cannot lock the index ({}); continuing", why);
}
}
}
// A config that could not be written must not take effect either: it
// would apply to this process, revert on restart, and show nothing
// unsaved in between.
if let Err(e) = new.save() {
// Put the lock back on the path the config still names, or this
// process would go on using the old index while guarding the new
// one — leaving the index it is actually writing open to a second
// instance.
if actions.search_db_changed {
let _ = IndexLock::move_to(&self.cfg.resolved_database_path());
}
self.config_error = Some(e);
return false;
}
@ -350,7 +386,7 @@ impl QuickSearchApp {
self.backend.coordinator.apply_config(new.clone());
if actions.requires_rebuild {
if self.backend.coordinator.state().mode == IndexMode::Auto {
self.backend.coordinator.rebuild_index();
self.backend.rebuild_index();
} else {
let changes = self
.backend
@ -440,7 +476,9 @@ impl QuickSearchApp {
if let Some(rx) = &self.backend.dup_job {
use std::sync::mpsc::TryRecvError;
let done = match rx.try_recv() {
Ok(Ok(groups)) => Some(DupState::Loaded(groups)),
Ok(Ok(groups)) => Some(DupState::Loaded(crate::duplicates_tab::LoadedGroups::new(
groups,
))),
Ok(Err(e)) => Some(DupState::Error(e)),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
@ -692,7 +730,8 @@ impl eframe::App for QuickSearchApp {
if let Some(paths) = actions.verify {
let paths: Vec<std::path::PathBuf> =
paths.into_iter().map(std::path::PathBuf::from).collect();
self.backend.start_verify(paths.clone(), ctx.clone());
self.backend
.start_verify(paths.clone(), &self.cfg, ctx.clone());
self.verify = Some(VerifyModal::new(paths));
}
}

View file

@ -50,7 +50,7 @@ impl QuickSearchApp {
));
ui.horizontal(|ui| {
if ui.button("Rebuild now").clicked() {
self.backend.coordinator.rebuild_index();
self.backend.rebuild_index();
close = true;
}
if ui.button("Later").clicked() {
@ -97,7 +97,7 @@ impl QuickSearchApp {
}
if stale_index_window(ctx, self.key_source) {
self.stale_index_prompt = false;
self.backend.coordinator.rebuild_index();
self.backend.rebuild_index();
self.dups.state = DupState::NotLoaded;
}
}
@ -158,6 +158,18 @@ impl QuickSearchApp {
group_thousands(*registered as u64),
));
}
// Not reachable through this modal today: an overflow leaves
// the watcher running and schedules a full run instead of
// disabling live updates, so it never becomes a
// `WatcherStatus::Disabled`. Spelled out rather than folded
// into a catch-all so that a future variant is a compile
// error here, which is how this arm came to be written.
WatchError::Overflowed => {
ui.label(
"The system dropped some change notifications, so the index \
is being rebuilt to catch up.",
);
}
WatchError::Other(msg) => {
ui.label(format!("Live updates are unavailable: {}", msg));
}
@ -227,7 +239,7 @@ impl QuickSearchApp {
// resurrect what was just deleted, nor the next launch
// undo the stop.
self.set_index_mode(false);
self.backend.coordinator.clear_index();
self.backend.clear_index();
self.dups.state = DupState::NotLoaded;
return true;
}

View file

@ -377,7 +377,7 @@ impl QuickSearchApp {
}
}
db::set_process_key(new_key);
self.backend.coordinator.rebuild_index();
self.backend.rebuild_index();
self.dups.state = DupState::NotLoaded;
}
}

View file

@ -51,6 +51,31 @@ pub struct Backend {
}
impl Backend {
/// Ask the coordinator to rebuild, after letting go of everything that
/// holds the index file open.
///
/// The search worker keeps its connection for half a minute after the last
/// keystroke so a typing session runs against a warm cache — which is
/// exactly the wrong thing to be holding when the file is about to be
/// deleted. Without this the delete fails on Windows and the rebuild
/// silently becomes an ordinary run against the old index; after a
/// password change that leaves the config claiming protection the file on
/// disk does not have.
pub fn rebuild_index(&self) {
if let Some(search) = &self.search {
search.release_connection();
}
self.coordinator.rebuild_index();
}
/// [`Backend::rebuild_index`]'s reasoning, for the delete-only path.
pub fn clear_index(&self) {
if let Some(search) = &self.search {
search.release_connection();
}
self.coordinator.clear_index();
}
pub fn start(config: &Config, ctx: egui::Context) -> Result<Backend, String> {
// eframe is reactive: a run the coordinator schedules on its own
// would sit unseen behind a settled window until the pointer moved.
@ -89,10 +114,17 @@ impl Backend {
pub fn watch_live(
&self,
query: &str,
targets: Vec<quicksearch_core::live::Target>,
mut targets: Vec<quicksearch_core::live::Target>,
config: &Config,
) {
let Some(live) = &self.live else { return };
// The watcher re-reads a row's file to re-cut its snippet, and the one
// file it must never open is the index it is reading the row from:
// closing a descriptor on it cancels SQLite's locks process-wide. The
// walk no longer writes such rows, but one from an older build lives
// until the stale sweep reaches its directory, and it can be on screen
// before then.
targets.retain(|t| !config.is_index_file(std::path::Path::new(&t.path)));
if targets.is_empty() {
live.clear();
} else {
@ -133,10 +165,13 @@ impl Backend {
/// Read a duplicate group through on a worker thread, comparing every
/// member against the first byte for byte. Replaces any run already going.
pub fn start_verify(&mut self, paths: Vec<PathBuf>, ctx: egui::Context) {
pub fn start_verify(&mut self, mut paths: Vec<PathBuf>, config: &Config, ctx: egui::Context) {
if let Some(job) = &self.verify_job {
job.cancel();
}
// Byte-for-byte comparison opens every member. See `watch_live` for
// why the index must not be one of them.
paths.retain(|p| !config.is_index_file(p));
let (tx, rx) = mpsc::channel();
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = cancel.clone();

View file

@ -10,6 +10,7 @@ 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 quicksearch_core::textenc::scrub_controls;
use zeroize::Zeroizing;
use crate::format::{fmt_mtime, human_size};
@ -127,15 +128,22 @@ pub(crate) fn resolve_key(
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(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) => match mismatch_cause(&e) {
Some((db::KeyMismatch::WrongPassword, _)) => {
eprintln!(
"warning: the key remembered in the OS keychain no longer \
opens this index"
);
}
// Not a stale keychain entry at all: the index is not
// encrypted, or wants a key this one is not. Say which,
// then fall through to the other sources as before.
Some((_, detail)) => eprintln!("warning: {}", detail),
None => return Err(e),
},
},
Err(e) => eprintln!("warning: {}", e),
}
}
@ -145,11 +153,17 @@ pub(crate) fn resolve_key(
drop(password);
return match try_key(key) {
Ok(()) => Ok(()),
Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => Err(format!(
Err(e) => match mismatch_cause(&e) {
Some((db::KeyMismatch::WrongPassword, _)) => Err(format!(
"{} does not match this index's password",
PASSWORD_ENV
)),
Err(e) => Err(e),
// Blaming the environment variable for "the index is not
// encrypted" sends the user to change the one thing that is
// not wrong.
Some((_, detail)) => Err(detail),
None => Err(e),
},
};
}
@ -168,15 +182,31 @@ pub(crate) fn resolve_key(
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(e) => match mismatch_cause(&e) {
// Only a wrong password is worth another attempt. The other
// two causes are facts about the index — it is not encrypted,
// or it wants a password this build did not apply — and
// retrying makes the user type a *correct* password twice more
// before being told something that was never about their
// typing. The same reasoning as the unlock screen's.
Some((db::KeyMismatch::WrongPassword, _)) => eprintln!("Wrong password."),
Some((_, detail)) => return Err(detail),
None => return Err(e),
},
}
}
Err("wrong password (3 attempts)".to_string())
}
/// [`db::key_mismatch_parts`] with the detail copied out.
///
/// Owned rather than borrowed so a caller can hand the original error back on
/// the `None` arm: `key_mismatch_parts` borrows from the message, and a `match`
/// on its result would hold that borrow across a `return Err(e)`.
fn mismatch_cause(error: &str) -> Option<(db::KeyMismatch, String)> {
db::key_mismatch_parts(error).map(|(cause, detail)| (cause, detail.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> {
@ -269,7 +299,21 @@ fn run_query(query: &str, fuzzy: bool, limit: Option<usize>, long: bool) -> i32
match outcome {
Ok(Some(outcome)) => {
let color = long && std::io::stdout().is_terminal() && enable_vt();
let tty = std::io::stdout().is_terminal();
let color = long && tty && enable_vt();
// Scrubbed only for a terminal, the way `ls` does it and the way
// the colour above already decides: a filename may contain escape
// sequences, and printed raw they rewrite the line, retitle the
// window, or reach the clipboard on a terminal with OSC 52 on.
// Piped output stays byte-exact, because the next program in the
// pipe wants the real name and interprets nothing.
let show = |s: &str| -> String {
if tty {
scrub_controls(s).into_owned()
} else {
s.to_string()
}
};
for hit in &hits {
if long {
println!(
@ -277,13 +321,13 @@ fn run_query(query: &str, fuzzy: bool, limit: Option<usize>, long: bool) -> i32
hit.rank,
human_size(hit.size),
fmt_mtime(hit.mtime),
hit.path
show(&hit.path)
);
if let Some(snip) = &hit.snippet {
println!(" {}", render_snippet(snip, color));
println!(" {}", render_snippet(snip, color, tty));
}
} else {
println!("{}", hit.path);
println!("{}", show(&hit.path));
}
}
if outcome.limited {
@ -334,28 +378,58 @@ fn enable_vt() -> bool {
}
/// One-line snippet with matches emphasized (ANSI bold on TTYs).
fn render_snippet(snip: &quicksearch_core::snippet::Snippet, color: bool) -> String {
fn render_snippet(snip: &quicksearch_core::snippet::Snippet, color: bool, tty: bool) -> String {
// The window is document text, so it can hold anything the file did —
// including the escape sequences this function is about to add its own
// emphasis codes around. Each piece is scrubbed as it goes in, before the
// codes, so the only escapes in the result are the ones put there here.
let mut out = String::new();
let push = |out: &mut String, piece: &str| {
// Line breaks are flattened *before* the scrub, not after the whole
// string is assembled. A snippet is one line by contract for both
// destinations, and `scrub_controls` turns `\n` into `U+FFFD` — so a
// trailing `replace(['\n', '\r'], " ")` would find nothing left to
// collapse on a terminal and print `<60>` where the space belongs.
let flat = flatten_lines(piece);
if tty {
out.push_str(&scrub_controls(&flat));
} else {
out.push_str(&flat);
}
};
if snip.truncated_start {
out.push('…');
}
let mut cursor = 0;
for &(start, end) in &snip.ranges {
out.push_str(&snip.window[cursor..start]);
push(&mut out, &snip.window[cursor..start]);
if color {
out.push_str("\x1b[1m");
out.push_str(&snip.window[start..end]);
push(&mut out, &snip.window[start..end]);
out.push_str("\x1b[0m");
} else {
out.push_str(&snip.window[start..end]);
push(&mut out, &snip.window[start..end]);
}
cursor = end;
}
out.push_str(&snip.window[cursor..]);
push(&mut out, &snip.window[cursor..]);
if snip.truncated_end {
out.push('…');
}
out.replace(['\n', '\r'], " ")
out
}
/// `piece` with line breaks flattened to spaces, borrowed when it has none.
///
/// A `Cow` for the same reason [`scrub_controls`] is one: this runs per
/// fragment of every snippet of every hit, and the overwhelmingly common case
/// has nothing to change.
fn flatten_lines(piece: &str) -> std::borrow::Cow<'_, str> {
if piece.contains(['\n', '\r']) {
std::borrow::Cow::Owned(piece.replace(['\n', '\r'], " "))
} else {
std::borrow::Cow::Borrowed(piece)
}
}
#[cfg(test)]
@ -372,7 +446,10 @@ mod tests {
}
fn mismatch() -> Result<(), String> {
Err(format!("{}wrong password", db::KEY_MISMATCH_PREFIX))
Err(format!(
"{}wrong-password: index at /x: wrong password",
db::KEY_MISMATCH_PREFIX
))
}
fn argv(args: &[&str]) -> Vec<String> {

View file

@ -9,10 +9,45 @@ use crate::ui_util::hint;
pub enum DupState {
NotLoaded,
Loading,
Loaded(Vec<DuplicateGroup>),
Loaded(LoadedGroups),
Error(String),
}
/// The scan's result, with each group's header line already built.
///
/// The titles are four formatted numbers each and the list runs to 500, so
/// building them in the render loop meant ~2000 allocations *per frame* — and
/// this list overflows by definition, which keeps `more_below_hint`'s 20 Hz
/// repaint running for as long as the tab is open. They depend only on the
/// data, so they are built once, here, where the data arrives.
pub struct LoadedGroups {
pub groups: Vec<DuplicateGroup>,
titles: Vec<String>,
}
impl LoadedGroups {
pub fn new(groups: Vec<DuplicateGroup>) -> LoadedGroups {
let titles = groups
.iter()
.map(|group| {
let name = group
.members
.first()
.map(|m| m.1.as_str())
.unwrap_or("(unknown)");
format!(
"{} × {}: {} reclaimable ({} total)",
group_thousands(group.count as u64),
name,
human_size(group.redundant_size.max(0) as u64),
human_size(group.total_size.max(0) as u64),
)
})
.collect();
LoadedGroups { groups, titles }
}
}
pub struct DuplicatesTab {
pub state: DupState,
}
@ -73,7 +108,8 @@ impl DuplicatesTab {
DupState::Error(e) => {
ui.colored_label(ui.visuals().error_fg_color, e);
}
DupState::Loaded(groups) => {
DupState::Loaded(loaded) => {
let groups = &loaded.groups;
if groups.is_empty() {
ui.label("No duplicate files found.");
return actions;
@ -85,18 +121,9 @@ impl DuplicatesTab {
.auto_shrink([false; 2])
.show(ui, |ui| {
for (i, group) in groups.iter().enumerate() {
let name = group
.members
.first()
.map(|m| m.1.as_str())
.unwrap_or("(unknown)");
let title = format!(
"{} × {}: {} reclaimable ({} total)",
group_thousands(group.count as u64),
name,
human_size(group.redundant_size.max(0) as u64),
human_size(group.total_size.max(0) as u64),
);
// Built once when the scan landed; see
// `LoadedGroups`.
let title = loaded.titles[i].as_str();
let header =
egui::CollapsingHeader::new(title)
.id_salt(i)

View file

@ -23,7 +23,7 @@ fn group(paths: &[&str]) -> DuplicateGroup {
fn loaded(paths: &[&str]) -> DuplicatesTab {
DuplicatesTab {
state: DupState::Loaded(vec![group(paths)]),
state: DupState::Loaded(LoadedGroups::new(vec![group(paths)])),
}
}
@ -177,7 +177,7 @@ fn a_member_row_offers_the_verification_too() {
fn an_empty_result_says_so_rather_than_showing_an_empty_list() {
let ctx = crate::test_ui::ctx();
let mut tab = DuplicatesTab {
state: DupState::Loaded(Vec::new()),
state: DupState::Loaded(LoadedGroups::new(Vec::new())),
};
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(

View file

@ -43,10 +43,14 @@ pub fn fmt_ago(unix_secs: u64) -> String {
/// A configured interval as a phrase to drop after "every": `90 min`,
/// `24 h`, `3 days`.
pub fn fmt_interval(minutes: u64) -> String {
if minutes == 0 {
// The scheduler treats 0 as always-due.
return "run".to_string();
}
// The same clamp the scheduler applies. Zero is only reachable from a
// hand-edited config — the spinner's floor is 5 — and it used to read
// "run", from a time when the scheduler took it as always-due. It no
// longer does (`coordinator::inner::periodic_due` raises it to one
// minute, because "reindex continuously" is not a setting anyone wants
// and manual mode is how you say "never"), so saying "run" here would
// describe a behaviour the app does not have.
let minutes = minutes.max(1);
if minutes < 60 {
return format!("{} min", minutes);
}
@ -170,7 +174,9 @@ mod tests {
#[test]
fn intervals() {
assert_eq!(fmt_interval(0), "run");
// Clamped, exactly as `periodic_due` clamps it: the two must not
// describe different behaviour.
assert_eq!(fmt_interval(0), "1 min");
assert_eq!(fmt_interval(1), "1 min");
assert_eq!(fmt_interval(59), "59 min");
assert_eq!(fmt_interval(60), "1 h", "the shipped default");

View file

@ -37,6 +37,7 @@ mod unlock;
mod version;
use quicksearch_core::config::Config;
use quicksearch_core::platform::{IndexLock, LockError};
/// The window icon, shown in the titlebar, taskbar and alt-tab switcher.
///
@ -82,6 +83,45 @@ fn main() {
};
let initial_query = seed_query();
// After the CLI early-exit above, deliberately: `quicksearch <query>` only
// reads, and must keep working from a terminal while the window is open.
// Two *windows* on one index are the problem — two indexers writing, and,
// once either has cancelled the other's SQLite locks, an attach that
// truncates the wal-index under a live mapping.
//
// Held for the life of the process in `platform`'s own slot, so the
// settings handler can move it when `database_path` changes; the kernel
// releases it on exit, however that exit happens.
match IndexLock::hold(&config.resolved_database_path()) {
Ok(()) => {}
Err(LockError::Held { pid }) => {
let who = match pid {
Some(pid) => format!(" (process {})", pid),
None => String::new(),
};
let msg = format!(
"QuickSearch is already running{}.\n\nOnly one window can use \
the index at a time. Switch to the running window, or close \
it and try again.",
who
);
eprintln!("{}", msg);
// The app is normally launched from a desktop icon or a hotkey,
// where nothing is watching stderr.
rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Info)
.set_title("QuickSearch")
.set_description(&msg)
.show();
std::process::exit(1);
}
// Not "the lock is taken" — the filesystem could not answer. A
// convenience guard is never a good enough reason to refuse to open.
Err(LockError::Unsupported(why)) => {
eprintln!("warning: cannot lock the index ({}); starting anyway", why);
}
}
// With protection on, try the keychain before the window opens; a
// verified key means no prompt at all. `None` starts locked, and no
// index is touched until unlocked.

View file

@ -211,19 +211,23 @@ impl UnlockScreen {
match result {
Ok(key) => return self.unlocked(ctx, key),
Err(e) => {
// A tagged mismatch has three distinct causes and
// `key_mismatch_message` already told them apart. Only one
// of them is a wrong password; the others are "the config
// says protected but the index on disk is not" (a crash
// between saving the config and rebuilding) and "this
// index wants a password at all". Collapsing them all into
// "Wrong password." tells a user with the right password
// that it is wrong, and the only button on this screen
// deletes their index and turns protection off. The detail
// names the database path and nothing secret.
self.error = Some(match e.strip_prefix(db::KEY_MISMATCH_PREFIX) {
Some(_) if e.contains("wrong password") => "Wrong password.".to_string(),
Some(detail) => detail.to_string(),
// A tagged mismatch has three distinct causes and the
// opener already told them apart. Only one is a wrong
// password; the others are "the config says protected but
// the index on disk is not" (a crash between saving the
// config and rebuilding) and "this index wants a password
// at all". Collapsing them all into "Wrong password."
// tells a user with the right password that it is wrong,
// and the only button on this screen deletes their index
// and turns protection off.
//
// Matched on the cause rather than on the wording: the
// message carries the database path, so a path that
// happens to contain the phrase would otherwise decide
// this. The detail names the path and nothing secret.
self.error = Some(match db::key_mismatch_parts(&e) {
Some((db::KeyMismatch::WrongPassword, _)) => "Wrong password.".to_string(),
Some((_, detail)) => detail.to_string(),
None => e,
});
// The field was cleared on submit; put the caret back

View file

@ -1 +0,0 @@
{"v":1}