Skip to content

Commit 35b382d

Browse files
committed
feat(stack-encrypt)!: let an inverted Between range through, as SQL does
A `Between` whose low bound is above its high bound was refused with `TermError::InvertedRange` before either term was derived. SQL's own `BETWEEN` refuses nothing: `x BETWEEN 20 AND 10` is a valid query that matches no row, and an encrypted range now behaves the same way. The empty result leaks nothing and corrupts nothing, so a refusal bought little. It would also cost more than it buys once text and floats are ordered. Judging their ranges correctly means reproducing, before encryption, the order the index compares in: text by its fold, floats by their canonical encoding. That order belongs to the ORE crates, not to this one. So `TermError::InvertedRange` (code `stack_encrypt::inverted_range`) goes, with the typed path's `lo > hi` check (and the `PartialOrd` bound `op::Ordered` carried for it) and the data path's `integer_order`. The variant is under [Unreleased] and never shipped.
1 parent d277e3d commit 35b382d

9 files changed

Lines changed: 82 additions & 194 deletions

File tree

‎languages/golang/encrypt/guest/tests/native_ops.rs‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1562,13 +1562,6 @@ fn query_term_validation_refuses_what_the_query_op_refuses() {
15621562
"between",
15631563
STATUS_ENCODING,
15641564
),
1565-
(
1566-
"inverted range",
1567-
FfiValue::Array(vec![FfiValue::UInt32(65), FfiValue::UInt32(18)]),
1568-
"age",
1569-
"between",
1570-
STATUS_TERM,
1571-
),
15721565
("no token", s("al"), "name", "matches", STATUS_TERM),
15731566
] {
15741567
let input = encode(input);

‎languages/golang/encrypt/query_test.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,9 @@ func TestAQueryTheFieldDoesNotAnswerIsRefused(t *testing.T) {
317317
t.Fatalf("%s on %s: %v, want field %q and reason %q", tc.operator, tc.field, err, tc.field, tc.reason)
318318
}
319319
}
320-
if _, err := cipher.QueryTerm(ctx, queryPlan, "age", "between", []any{uint32(65), uint32(18)}); !errors.Is(err, encrypt.ErrTerm) {
321-
t.Fatalf("an inverted range: %v, want ErrTerm", err)
320+
// An inverted range is not refused, as SQL's BETWEEN is not.
321+
if _, err := cipher.QueryTerm(ctx, queryPlan, "age", "between", []any{uint32(65), uint32(18)}); err != nil {
322+
t.Fatalf("an inverted range: %v", err)
322323
}
323324
if _, err := cipher.QueryTerm(ctx, queryPlan, "name", "matches", "al"); !errors.Is(err, encrypt.ErrTerm) {
324325
t.Fatalf("text that cuts no token: %v, want ErrTerm", err)
@@ -499,9 +500,9 @@ func TestGeneratedQueriesAreTheRustFixtures(t *testing.T) {
499500
t.Errorf("%s.Value() = %T %v", label, got, err)
500501
}
501502
}
502-
// An inverted range is the typed path's refusal, from the engine.
503-
if _, err := fields.Age.Between(ctx, cipher, 65, 18); !errors.Is(err, encrypt.ErrTerm) {
504-
t.Errorf("an inverted range: %v, want ErrTerm", err)
503+
// An inverted range is not refused, as SQL's BETWEEN is not.
504+
if _, err := fields.Age.Between(ctx, cipher, 65, 18); err != nil {
505+
t.Errorf("an inverted range: %v", err)
505506
}
506507
}
507508

‎packages/stack-encrypt/CHANGELOG.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
110110
for the value (`OreTerm`, `OpeTerm`); a range's is one `sem::RangeQuery`
111111
carrying both bounds' terms, read with `lo()` and `hi()`, so no statement
112112
can pair the low bound of one query with the high bound of another. A
113-
range whose low bound is above its high bound is refused when the query
114-
is awaited, before either term is derived (`TermError::InvertedRange`).
113+
range whose low bound is above its high bound is not refused: as SQL's
114+
`BETWEEN`, it matches nothing.
115115
What a query takes is the operator's `op::Arity` (`op::One` or
116116
`op::Pair`), and `op::Input` relates the value given to the plaintext the
117117
plan is checked against (ADR-0009, decision 7).

‎packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -572,9 +572,12 @@ the ranked loop is worth packaging; it is deferred, not refused.
572572

573573
`cipher.query(op::Between, &(lo, hi)).on(&plan)` produces one term,
574574
`sem::RangeQuery`, carrying the index's term for each bound (the ORE left
575-
ciphertexts, once block ORE lands), inclusive at both ends, and the client
576-
refuses `lo > hi` (`TermError::InvertedRange`) before either term is
577-
derived; `(v, v)` is one value. One call derives both bounds, under one
575+
ciphertexts, once block ORE lands), inclusive at both ends; `(v, v)` is one
576+
value. The client does not refuse `lo > hi`: as SQL's `BETWEEN`, such a
577+
range matches nothing. Judging it would mean reproducing, before
578+
encryption, the order the index compares in (text by its fold, floats by
579+
their canonical encoding), which belongs to the ORE crates. One call
580+
derives both bounds, under one
578581
context, so a caller cannot pair a bound from one column with a bound from
579582
another. The ordering operators are answered over the integer types and
580583
text (`op::Ordered`), by the ORE index by default and by OPE through

‎packages/stack-encrypt/src/codes.rs‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ mod tests {
8181
TermError::Ore(_) => TermError::Ore(cllw_ore::Error),
8282
TermError::InvalidOptions(_) => TermError::InvalidOptions("k out of range"),
8383
TermError::EmptyTermText => TermError::EmptyTermText,
84-
TermError::InvertedRange => TermError::InvertedRange,
8584
TermError::NoEqualityTerm => TermError::NoEqualityTerm,
8685
TermError::NoOrderTerm => TermError::NoOrderTerm,
8786
TermError::Normalize(_) => {
@@ -396,7 +395,6 @@ mod tests {
396395
json!({ "len": 3 }),
397396
),
398397
(Box::new(TermError::EmptyTermText), json!({})),
399-
(Box::new(TermError::InvertedRange), json!({})),
400398
(Box::new(TermError::NoEqualityTerm), json!({})),
401399
(Box::new(TermError::NoOrderTerm), json!({})),
402400
(

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

Lines changed: 30 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@
6565
//! [`Probabilistic::verify`]: crate::op::Probabilistic::verify
6666
//! [`Probabilistic::target_limit`]: crate::op::Probabilistic::target_limit
6767
68-
use std::cmp::Ordering;
6968
use std::fmt;
7069

7170
use stack_kms::DataKeySource;
@@ -578,19 +577,15 @@ fn not_a_range(field: &str) -> Error {
578577
}
579578

580579
/// The refusals the typed path makes when a query is awaited, before any
581-
/// term exists ([`Supports::check`](crate::op::Supports::check)): a range
582-
/// whose low bound is above its high bound, and match text the index
583-
/// cannot normalize or that cuts no token. The match index's own
584-
/// derivation refuses the last two again; asking here lets a binding
585-
/// refuse at its boundary.
580+
/// term exists ([`Supports::check`](crate::op::Supports::check)): match
581+
/// text the index cannot normalize or that cuts no token, and equality
582+
/// text the scheme cannot normalize. Each index's own derivation refuses
583+
/// these again; asking here lets a binding refuse at its boundary. A range
584+
/// whose low bound is above its high bound is not refused: as SQL's
585+
/// `BETWEEN`, it matches nothing.
586586
fn check_input(index: &IndexSpec, input: &Input) -> Result<(), Error> {
587587
let refused = |error: TermError| Error::Cipher(error.into());
588588
match (index, input) {
589-
(_, Input::Pair(lo, hi)) => {
590-
if integer_order(lo, hi) == Some(Ordering::Greater) {
591-
return Err(refused(TermError::InvertedRange));
592-
}
593-
}
594589
(IndexSpec::Match(options), Input::One(value)) => {
595590
query_tokens(value, options).map(drop)?;
596591
}
@@ -605,33 +600,6 @@ fn check_input(index: &IndexSpec, input: &Input) -> Result<(), Error> {
605600
Ok(())
606601
}
607602

608-
/// The order of two integers of one kind, or `None` for anything else.
609-
fn integer_order(lo: &ValueFrame, hi: &ValueFrame) -> Option<Ordering> {
610-
let (lo_kind, lo) = integer_of(lo)?;
611-
let (hi_kind, hi) = integer_of(hi)?;
612-
(lo_kind == hi_kind).then(|| lo.cmp(&hi))
613-
}
614-
615-
/// An integer scalar's kind and value, or `None` for anything else. The
616-
/// value is its sign and 128-bit two's-complement word (vitaminc's
617-
/// [`Int::sign_and_word`](vitaminc_aead::plaintext::Int::sign_and_word)),
618-
/// ordered negatives first, so one comparison covers every width from
619-
/// `i128::MIN` to `u128::MAX`.
620-
fn integer_of(value: &ValueFrame) -> Option<(ValueKind, (bool, u128))> {
621-
let kind = value.kind()?;
622-
let (negative, word) = value
623-
.visit(OneScalar::new(
624-
|ty: Option<Logical>, value: Physical<'_>| match (ty, value) {
625-
(None, Physical::Int(int)) => Ok(int.sign_and_word()),
626-
_ => Err(None),
627-
},
628-
))
629-
.ok()?;
630-
// `!negative` so a negative sorts below every non-negative; within a
631-
// sign the two's-complement words order as the values do.
632-
Some((kind, (!negative, word)))
633-
}
634-
635603
/// The tokens a match query of `value` keeps, under the index's options.
636604
///
637605
/// # Errors
@@ -729,10 +697,11 @@ pub fn check_query(
729697
/// field does not answer ([`Reason::OperatorNotAnswered`]), an input that
730698
/// cannot be read as the field's kind ([`Reason::FieldType`]) and a
731699
/// `between` input that is not two values ([`Reason::NotARange`]).
732-
/// [`Error::Cipher`] for the query refusals the typed path makes: a range
733-
/// whose low bound is above its high ([`TermError::InvertedRange`]), and
734-
/// match text that cuts no token ([`TermError::EmptyTermText`]) or that
735-
/// the index cannot normalize ([`TermError::Normalize`]).
700+
/// [`Error::Cipher`] for the query refusals the typed path makes: match
701+
/// text that cuts no token ([`TermError::EmptyTermText`]), and text the
702+
/// index cannot normalize ([`TermError::Normalize`]). A range whose low
703+
/// bound is above its high bound is not refused: as SQL's `BETWEEN`, it
704+
/// matches nothing.
736705
/// [`Error::Target`] for what the resolver refuses.
737706
pub fn query_term<'a, K>(
738707
cipher: &'a KeysetCipher<'_, K>,
@@ -1092,7 +1061,7 @@ mod tests {
10921061
keyset: &KeysetCipher<'_, FakeDataKeySource>,
10931062
name: &str,
10941063
operator: OpSpec,
1095-
input: FfiValue,
1064+
input: impl Encrypt,
10961065
) -> Result<QueryTerm, Error> {
10971066
query_term(keyset, &users(), name, operator, input, &NoTargets)?
10981067
.await
@@ -1437,9 +1406,7 @@ mod tests {
14371406
async fn the_typed_paths_query_refusals_are_made_before_any_term() {
14381407
let cipher = cipher().await;
14391408
let keyset = cipher.default_keyset();
1440-
let pair = |lo, hi| FfiValue::Array(vec![FfiValue::UInt32(lo), FfiValue::UInt32(hi)]);
14411409
let cases = [
1442-
("age", OpSpec::Between, pair(65, 18), "inverted"),
14431410
("name", OpSpec::Matches, text("al"), "empty"),
14441411
("name", OpSpec::Matches, text("\u{0378}abc"), "normalize"),
14451412
("email", OpSpec::Eq, text("a\u{0378}bc"), "normalize"),
@@ -1451,7 +1418,6 @@ mod tests {
14511418
] {
14521419
let term = term_error(&error);
14531420
let expected = match what {
1454-
"inverted" => matches!(term, Some(TermError::InvertedRange)),
14551421
"empty" => matches!(term, Some(TermError::EmptyTermText)),
14561422
_ => matches!(term, Some(TermError::Normalize(_))),
14571423
};
@@ -1927,8 +1893,8 @@ mod tests {
19271893
])
19281894
.unwrap();
19291895
assert!(
1930-
ask("huge", OpSpec::Between, inverted).await.is_err(),
1931-
"an inverted 128-bit range is refused"
1896+
ask("huge", OpSpec::Between, inverted).await.is_ok(),
1897+
"an inverted 128-bit range is not refused"
19321898
);
19331899

19341900
let stored = |value: &ValueFrame, name: &'static str| {
@@ -2003,53 +1969,23 @@ mod tests {
20031969
);
20041970
}
20051971

2006-
/// A range is refused the wrong way round for every integer kind a
2007-
/// field can declare, and two values of different kinds have no order
2008-
/// here: the input was read as one kind first.
2009-
#[test]
2010-
fn a_range_is_ordered_within_each_integer_kind() {
2011-
let cases = [
2012-
(FfiValue::Int32(-1), FfiValue::Int32(1)),
2013-
(FfiValue::Int64(-1), FfiValue::Int64(1)),
2014-
(FfiValue::UInt32(1), FfiValue::UInt32(2)),
2015-
(FfiValue::UInt64(1), FfiValue::UInt64(2)),
2016-
];
2017-
for (lo, hi) in cases {
2018-
let (lo, hi) = (vf(&lo), vf(&hi));
2019-
assert_eq!(integer_order(&lo, &hi), Some(Ordering::Less));
2020-
assert_eq!(integer_order(&hi, &lo), Some(Ordering::Greater));
2021-
assert_eq!(integer_order(&lo, &lo), Some(Ordering::Equal));
2022-
}
2023-
assert_eq!(
2024-
integer_order(&vf(&FfiValue::UInt32(1)), &vf(&FfiValue::UInt64(2))),
2025-
None
2026-
);
2027-
assert_eq!(integer_order(&vf(&text("a")), &vf(&text("b"))), None);
2028-
// Every width, the 128-bit edges included: a `u128` above
2029-
// `i128::MAX` and a negative `i128` order as the numbers do.
2030-
let int = |int: vitaminc_aead::plaintext::Int| {
2031-
ValueFrame::of(vitaminc_aead::plaintext::Raw(
2032-
None,
2033-
vitaminc_aead::plaintext::Physical::Int(int),
2034-
))
2035-
.expect("a value")
1972+
/// A range whose low bound is above its high bound is not refused, as
1973+
/// SQL's `BETWEEN` is not: it is checked, and its terms derived, like
1974+
/// any other range, and matches nothing.
1975+
#[tokio::test]
1976+
async fn an_inverted_range_is_a_query_like_any_other() {
1977+
let cipher = cipher().await;
1978+
let keyset = cipher.default_keyset();
1979+
// `score` declares OPE alone, whose terms compare as bytes.
1980+
let inverted = ValueFrame::of(vec![65i64, 18]).expect("a value");
1981+
assert!(check_query(&users(), "score", OpSpec::Between, &inverted, &NoTargets).is_ok());
1982+
let term = query(&keyset, "score", OpSpec::Between, &inverted)
1983+
.await
1984+
.expect("derived");
1985+
let Operand::Range { lo, hi } = term.operand() else {
1986+
panic!("a range");
20361987
};
2037-
use vitaminc_aead::plaintext::Int;
2038-
for (lo, hi) in [
2039-
(Int::I8(-1), Int::I8(1)),
2040-
(Int::I16(-1), Int::I16(1)),
2041-
(Int::I128(i128::MIN), Int::I128(i128::MAX)),
2042-
(Int::I128(-1), Int::I128(0)),
2043-
(Int::U8(1), Int::U8(2)),
2044-
(Int::U16(1), Int::U16(2)),
2045-
(Int::U128(i128::MAX as u128), Int::U128(u128::MAX)),
2046-
] {
2047-
let (lo, hi) = (int(lo), int(hi));
2048-
assert_eq!(integer_order(&lo, &hi), Some(Ordering::Less));
2049-
assert_eq!(integer_order(&hi, &lo), Some(Ordering::Greater));
2050-
assert_eq!(integer_order(&lo, &lo), Some(Ordering::Equal));
2051-
}
2052-
assert_eq!(integer_order(&int(Int::I8(1)), &int(Int::I16(2))), None);
1988+
assert!(lo > hi, "the bounds as given, and the terms in their order");
20531989
}
20541990

20551991
#[test]

‎packages/stack-encrypt/src/op/mod.rs‎

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,15 @@
6161
//! plan is data, so there an index the field does not declare, or declares
6262
//! with other options, is refused when the query is awaited, before any
6363
//! key is requested. So is a match configuration whose [`MatchCase`]
64-
//! disagrees with its options, and so are the two query values the library
64+
//! disagrees with its options, and so is the one query value the library
6565
//! refuses: match text that cuts no token under the index's tokenizer,
66-
//! whose filter would match every row ([`TermError::EmptyTermText`]), and
67-
//! a `Between` whose low bound is above its high bound
68-
//! ([`TermError::InvertedRange`]). The stored side has no such refusal: a
69-
//! value with no tokens is stored as the empty filter, which no query
70-
//! matches.
66+
//! whose filter would match every row ([`TermError::EmptyTermText`]). The
67+
//! stored side has no such refusal: a value with no tokens is stored as
68+
//! the empty filter, which no query matches. A `Between` whose low bound is
69+
//! above its high bound is not refused either: as SQL's `BETWEEN`, it
70+
//! matches nothing.
7171
//!
7272
//! [`TermError::EmptyTermText`]: crate::sem::TermError::EmptyTermText
73-
//! [`TermError::InvertedRange`]: crate::sem::TermError::InvertedRange
7473
//! [`RangeQuery`]: crate::sem::RangeQuery
7574
//!
7675
//! The module is meant to be used by path (`op::Eq`): an imported `Eq`
@@ -88,7 +87,7 @@ pub use stats::{Limit, Stats, DEFAULT_CONFIDENCE, MAX_ROWS};
8887

8988
use crate::sem::{
9089
CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, EqualityTerm, MatchConfig, MatchQuery,
91-
MatchTerms, OpeTerm, OreTerm, QueryTokens, RangeQuery, TermError,
90+
MatchTerms, OpeTerm, OreTerm, QueryTokens, RangeQuery,
9291
};
9392
use crate::target::{DeclaredContext, Equality, Index, Match, Ope, Ore, Owned, Pending};
9493
use crate::{Error, KeysetCipher, Label, NonEmpty, PlanError};
@@ -133,10 +132,9 @@ pub struct Gte;
133132
/// at both ends. It takes a [`Pair`], `cipher.query(op::Between, &(lo, hi))`,
134133
/// derives both bounds under one context, and sends them as one term
135134
/// ([`RangeQuery`]), so no statement can pair the
136-
/// low bound of one query with the high bound of another. A pair whose low
137-
/// bound is above its high bound is refused when the query is awaited
138-
/// ([`TermError::InvertedRange`]); one
139-
/// value is `(v, v)`.
135+
/// low bound of one query with the high bound of another. One value is
136+
/// `(v, v)`. A pair whose low bound is above its high bound is not
137+
/// refused: as SQL's `BETWEEN`, it matches nothing.
140138
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
141139
pub struct Between;
142140

@@ -347,16 +345,15 @@ impl<S: 'static> Input<Pair> for (S, S) {
347345
/// Text is not one: its order term is a catalog question (ADR-0009, ADR-0002),
348346
/// and an ordering on a text plan does not compile.
349347
///
350-
/// Sealed: `Between` refuses an inverted range with the type's `PartialOrd`
351-
/// while the server compares ORE terms, so a type whose two orders could
352-
/// disagree (a newtype over folded text, say) must not be able to join. A
353-
/// type that meets every other bound is still refused:
348+
/// Sealed: a type joins only when the order its terms compare in is the
349+
/// one its operators promise, so a type whose terms could order otherwise
350+
/// (a newtype over folded text, say) must not be able to join. A type that
351+
/// meets every other bound is still refused:
354352
///
355353
/// ```compile_fail
356354
/// # use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt, Error, Key};
357355
/// use stack_encrypt::op::Ordered;
358356
///
359-
/// #[derive(PartialEq, PartialOrd)]
360357
/// struct Folded(u32);
361358
/// # impl CllwOreEncrypt for Folded {
362359
/// # type Output = <u32 as CllwOreEncrypt>::Output;
@@ -380,7 +377,6 @@ impl<S: 'static> Input<Pair> for (S, S) {
380377
)]
381378
pub trait Ordered:
382379
sealed::Sealed
383-
+ PartialOrd
384380
+ CllwOreEncrypt<Output: Send + 'static>
385381
+ CllwOpeEncrypt<Output: Send + 'static>
386382
+ Send
@@ -644,9 +640,8 @@ pub trait Supports<Op: Operator, In: Input<Op::Arity>>: Index<In::Plaintext> {
644640
/// Why this index cannot answer `Op` as configured, or for `input`, if
645641
/// it cannot: a match index whose [`MatchCase`] disagrees with its
646642
/// options ([`PlanError::MatchCase`]), a match query of text that cuts
647-
/// no token ([`TermError::EmptyTermText`]), whose filter would match
648-
/// every row, or a range whose low bound is above its high bound
649-
/// ([`TermError::InvertedRange`]).
643+
/// no token ([`TermError::EmptyTermText`](crate::sem::TermError::EmptyTermText)),
644+
/// whose filter would match every row.
650645
/// Run when the query is awaited, before any key is requested; an
651646
/// index that refuses nothing returns `Ok(())`.
652647
fn check(&self, input: &In) -> Result<(), Error>;
@@ -744,12 +739,8 @@ macro_rules! orders {
744739
impl<S: Ordered> Supports<Between, (S, S)> for $index {
745740
type Query = RangeQuery<$term<S>>;
746741
type Verifier = ();
747-
fn check(&self, (lo, hi): &(S, S)) -> Result<(), Error> {
748-
if lo > hi {
749-
Err(TermError::InvertedRange.into())
750-
} else {
751-
Ok(())
752-
}
742+
fn check(&self, _: &(S, S)) -> Result<(), Error> {
743+
Ok(())
753744
}
754745
fn verifier(&self, _: &(S, S)) {}
755746
fn query((): (), (lo, hi): ($term<S>, $term<S>), _: QueryToken) -> Self::Query {
@@ -940,18 +931,15 @@ mod tests {
940931
arity::<AtPath<Between>, Pathed<Pair>>();
941932
}
942933

943-
/// The ordering operators default to ORE, and a range is refused only
944-
/// when its bounds are the wrong way round: `(v, v)` is one value.
934+
/// An ordering refuses no value: a range whose bounds are the wrong way
935+
/// round matches nothing, as SQL's `BETWEEN` does.
945936
#[test]
946-
fn a_range_is_checked_before_any_term_is_derived() {
937+
fn an_ordering_refuses_no_value() {
947938
let ok = |pair: (u32, u32)| Supports::<Between, (u32, u32)>::check(&Ore, &pair).is_ok();
948939
assert!(ok((1, 2)));
949940
assert!(ok((2, 2)));
950-
assert!(!ok((3, 2)));
951-
assert!(matches!(
952-
Supports::<Between, (i64, i64)>::check(&Ope, &(1, -1)),
953-
Err(Error::Term(TermError::InvertedRange))
954-
));
941+
assert!(ok((3, 2)));
942+
assert!(Supports::<Between, (i64, i64)>::check(&Ope, &(1, -1)).is_ok());
955943
assert!(Supports::<Lt, u8>::check(&Ore, &7).is_ok());
956944
assert!(Supports::<Gte, i128>::check(&Ope, &-7).is_ok());
957945
}

0 commit comments

Comments
 (0)