Skip to content

Commit 451d452

Browse files
committed
fix(stack-encrypt): refuse invalid match options before a query cuts tokens
FieldPlan::new is public and does not check the MatchOptions of a match output, so a plan built by hand can carry an n-gram length of zero. The term derivation validates them, but check_query and query_term reach the tokenizer first through check_input, and verify calls it directly, so such a plan panicked in the tokenizer rather than returning InvalidOptions. query_tokens now validates the options first.
1 parent 66f4f76 commit 451d452

1 file changed

Lines changed: 48 additions & 4 deletions

File tree

  • packages/stack-encrypt/src/dynamic

‎packages/stack-encrypt/src/dynamic/query.rs‎

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -490,10 +490,13 @@ fn integer_order(lo: &FfiValue, hi: &FfiValue) -> Option<Ordering> {
490490
///
491491
/// # Errors
492492
///
493-
/// [`Error::Cipher`] with [`TermError::Normalize`] for text the index cannot
494-
/// normalize, and [`TermError::EmptyTermText`] for text that cuts no token,
495-
/// whose filter would match every row. [`Error::Internal`] for a value that
496-
/// is not text, which [`read_input`] has ruled out.
493+
/// [`Error::Cipher`] with [`TermError::InvalidOptions`] for options the
494+
/// index cannot use (a [`FieldPlan`] built by hand is not
495+
/// checked, and an n-gram length of zero would panic in the tokenizer),
496+
/// [`TermError::Normalize`] for text the index cannot normalize, and
497+
/// [`TermError::EmptyTermText`] for text that cuts no token, whose filter
498+
/// would match every row. [`Error::Internal`] for a value that is not text,
499+
/// which [`read_input`] has ruled out.
497500
fn query_tokens(value: &FfiValue, options: &MatchOptions) -> Result<QueryTokens, Error> {
498501
let FfiValue::String(text) = value else {
499502
return Err(Error::Internal);
@@ -1408,6 +1411,47 @@ mod tests {
14081411
);
14091412
}
14101413

1414+
/// Match options on a field plan built by hand are not checked when the
1415+
/// plan is, so the query chain refuses them before it cuts a token: an
1416+
/// n-gram length of zero would otherwise panic in the tokenizer.
1417+
#[test]
1418+
fn invalid_match_options_are_refused_before_tokenising() {
1419+
let options = MatchOptions {
1420+
tokenizer: crate::sem::Tokenizer::Ngram { length: 0 },
1421+
..MatchOptions::default()
1422+
};
1423+
let context = crate::dynamic::context(FfiValue::Array(vec![text("users"), text("name")]))
1424+
.expect("context");
1425+
let field = FieldPlan::new(
1426+
"name",
1427+
context,
1428+
vec![record::Output::Term(IndexSpec::Match(options))],
1429+
)
1430+
.and_then(|field| field.with_type(ValueKind::String))
1431+
.expect("the field");
1432+
let plan = Plan::new(vec![field]).expect("the plan");
1433+
let invalid = |error: Error| {
1434+
assert!(
1435+
matches!(term_error(&error), Some(TermError::InvalidOptions(_))),
1436+
"{error:?}"
1437+
);
1438+
};
1439+
invalid(
1440+
check_query(&plan, "name", OpSpec::Matches, text("alice"), &NoTargets).unwrap_err(),
1441+
);
1442+
invalid(
1443+
verify(
1444+
&plan,
1445+
"name",
1446+
OpSpec::Matches,
1447+
text("alice"),
1448+
&[text("alice")],
1449+
&NoTargets,
1450+
)
1451+
.unwrap_err(),
1452+
);
1453+
}
1454+
14111455
/// `target_limit` is the typed `MatchQuery::target_limit` for the term
14121456
/// `query_term` returned, read from its bytes.
14131457
#[tokio::test]

0 commit comments

Comments
 (0)