diff --git a/app/helpers/searches_helper.rb b/app/helpers/searches_helper.rb index f3b22e69..754f898d 100644 --- a/app/helpers/searches_helper.rb +++ b/app/helpers/searches_helper.rb @@ -23,6 +23,6 @@ def highlight_searched_content(leaf, content, query) private def whole_word_matchers(terms) - terms.map { |term| /\b#{term}\b/ } + terms.map { |term| /\b#{Regexp.escape(term)}\b/ } end end diff --git a/app/models/leaf/searchable.rb b/app/models/leaf/searchable.rb index d90c5c91..672e8048 100644 --- a/app/models/leaf/searchable.rb +++ b/app/models/leaf/searchable.rb @@ -15,9 +15,9 @@ def reindex_all end def sanitize_query_syntax(terms) - terms = terms.to_s + terms = terms.to_s.scrub terms = remove_invalid_search_characters(terms) - terms = remove_unbalanced_quotes(terms) + terms = quote_query_tokens(terms) terms.presence end @@ -50,6 +50,8 @@ def matches_for_highlight(terms) .pick(Arel.sql("highlight(leaf_search_index, 1, '', '')")) content ? unique_matching_terms(content) : [] + else + [] end end @@ -106,12 +108,22 @@ def remove_invalid_search_characters(terms) terms.gsub(/[^\w"]/, " ") end - def remove_unbalanced_quotes(terms) - if terms.count("\"").even? - terms - else - terms.gsub("\"", " ") - end + # After stripping the characters FTS5 can't tokenize, the remaining + # input may still be an FTS5 boolean operator (AND/OR/NOT/NEAR) or + # carry an unbalanced double quote — either of which makes SQLite raise + # a syntax error. Rebuild the query from its balanced "quoted phrases" + # and bare words, wrapping every token as a quoted string literal so + # arbitrary input is matched literally instead of parsed as syntax. + # + # Match empty quote pairs too (`[^"]*`, not `+`) so a stray `""` is + # consumed in place rather than pairing its closing quote with the next + # opening one — which would shift the boundaries of a following phrase + # and split it into separate word matches. Empty tokens are then dropped. + def quote_query_tokens(terms) + terms.scan(/"[^"]*"|\w+/) + .filter_map { |token| token.delete('"').presence } + .map { |token| %("#{token}") } + .join(" ") end end end diff --git a/test/controllers/books/searches_controller_test.rb b/test/controllers/books/searches_controller_test.rb index 00777312..13be87b5 100644 --- a/test/controllers/books/searches_controller_test.rb +++ b/test/controllers/books/searches_controller_test.rb @@ -41,6 +41,21 @@ class Books::SearchesControllerTest < ActionDispatch::IntegrationTest assert_select "p", text: /no matches/i end + test "create shows no matches when the search uses FTS5 operator syntax" do + [ "OR", "AND", "NOT", "great OR", "NEAR handbook", "great AND NOT" ].each do |query| + post book_search_path(books(:handbook)), params: { search: query } + + assert_response :success, "expected #{query.inspect} to render without error" + end + end + + test "create still finds matches for an ordinary multi-word query" do + post book_search_path(books(:handbook)), params: { search: "great handbook" } + + assert_response :success + assert_select "a.search__result" + end + test "create does not find trashed pages" do leaves(:summary_page).trashed! diff --git a/test/controllers/leafables_controller_test.rb b/test/controllers/leafables_controller_test.rb index d9e383c7..626ac569 100644 --- a/test/controllers/leafables_controller_test.rb +++ b/test/controllers/leafables_controller_test.rb @@ -30,6 +30,44 @@ class LeafablesControllerTest < ActionDispatch::IntegrationTest assert_select "mark", "great" end + test "show does not raise when the search query sanitizes to empty" do + sign_out + books(:handbook).update!(published: true) + Leaf.reindex_all + + [ "^$", "!!!", "🙂", "\"" ].each do |query| + get leafable_slug_path(leaves(:welcome_page)), params: { search: query } + + assert_response :success, "expected #{query.inspect} to render without error" + assert_in_body "a great handbook." + end + end + + test "show does not raise when the search query uses FTS5 operator syntax" do + sign_out + books(:handbook).update!(published: true) + Leaf.reindex_all + + [ "OR", "AND", "NOT", "great OR", "NEAR handbook", "great AND NOT" ].each do |query| + get leafable_slug_path(leaves(:welcome_page)), params: { search: query } + + assert_response :success, "expected #{query.inspect} to render without error" + end + end + + test "show does not raise when a phrase match spans regex metacharacters" do + sign_out + books(:handbook).update!(published: true) + + sections(:welcome).update!(body: "alpha(beta gamma in the body") + leaves(:welcome_section).reindex + + get leafable_slug_path(leaves(:welcome_section)), params: { search: "alpha_beta" } + + assert_response :success + assert_select "mark", text: /alpha\(beta/ + end + test "show does not allow public access to an unpublished book" do sign_out diff --git a/test/helpers/searches_helper_test.rb b/test/helpers/searches_helper_test.rb index 7b144406..8dc6cb7f 100644 --- a/test/helpers/searches_helper_test.rb +++ b/test/helpers/searches_helper_test.rb @@ -1,6 +1,8 @@ require "test_helper" class SearchesHelperTest < ActionView::TestCase + include PagesHelper + test "sanitize_search_result preserves mark tags" do assert_equal "findme text", sanitize_search_result("findme text") end @@ -16,4 +18,14 @@ class SearchesHelperTest < ActionView::TestCase test "sanitize_search_result strips attributes from mark tags" do assert_equal "findme text", sanitize_search_result(' text') end + + test "highlight_searched_content handles matched terms containing regex metacharacters" do + leaf = Struct.new(:terms) do + def matches_for_highlight(_query) = terms + end.new([ "alpha(beta" ]) + + result = highlight_searched_content(leaf, "alpha(beta in the body", "alpha beta") + + assert_includes result, "alpha(beta" + end end diff --git a/test/models/leaf/searchable_test.rb b/test/models/leaf/searchable_test.rb index b94d77b5..5ab163ea 100644 --- a/test/models/leaf/searchable_test.rb +++ b/test/models/leaf/searchable_test.rb @@ -38,6 +38,41 @@ class Leaf::SearchableTest < ActiveSupport::TestCase assert_empty markup end + test "matches_for_highlight is empty when the query sanitizes to nothing" do + assert_empty leaves(:welcome_page).matches_for_highlight("^$") + assert_empty leaves(:welcome_page).matches_for_highlight("🙂") + assert_empty leaves(:welcome_page).matches_for_highlight("\"") + end + + test "search treats FTS5 operators as literal terms rather than syntax" do + assert_empty Leaf.search("OR") + assert_empty Leaf.search("great AND NOT") + assert_empty Leaf.search("great OR handbook") + + assert_includes Leaf.search("great handbook"), leaves(:welcome_page) + assert_includes Leaf.search("\"great handbook\""), leaves(:welcome_page) + end + + test "a stray empty quote pair does not split a following phrase" do + sections(:welcome).update!(body: "great old handbook") + leaves(:welcome_section).reindex + + results = Leaf.search("\"\" \"great handbook\"") + + assert_includes results, leaves(:welcome_page) + assert_not_includes results, leaves(:welcome_section) + end + + test "search does not raise on invalid UTF-8 byte sequences" do + malformed = "caf\xFF".dup.force_encoding("UTF-8") + assert_not malformed.valid_encoding? + + assert_nothing_raised do + assert_empty Leaf.search(malformed) + assert_empty leaves(:welcome_page).matches_for_highlight(malformed) + end + end + test "indexing sanitizes section body" do section = Section.new(body: 'findme Tom & Jerry ') books(:handbook).press(section, title: "Safe Title")