diff --git a/Cargo.lock b/Cargo.lock index 057682c..b6bf016 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -802,6 +802,7 @@ name = "protovalidate-buffa" version = "0.2.0" dependencies = [ "buffa", + "buffa-types", "cel", "chrono", "connectrpc", diff --git a/crates/protovalidate-buffa/Cargo.toml b/crates/protovalidate-buffa/Cargo.toml index bb16750..00f0df9 100644 --- a/crates/protovalidate-buffa/Cargo.toml +++ b/crates/protovalidate-buffa/Cargo.toml @@ -20,6 +20,7 @@ connect = ["dep:connectrpc"] [dependencies] buffa = "0.5" +buffa-types = "0.5" protovalidate-buffa-macros = { path = "../protovalidate-buffa-macros", version = "0.2.0" } regex = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } diff --git a/crates/protovalidate-buffa/src/cel.rs b/crates/protovalidate-buffa/src/cel.rs index 55aedff..dd683a8 100644 --- a/crates/protovalidate-buffa/src/cel.rs +++ b/crates/protovalidate-buffa/src/cel.rs @@ -111,6 +111,49 @@ impl ToCelValue for buffa::MessageField { } } +// --------------------------------------------------------------------------- +// Well-Known Type `AsCelValue` impls. +// +// These let plugin-emitted code call +// `::protovalidate_buffa::cel::AsCelValue::as_cel_value(inner)` for fields of +// type `google.protobuf.FieldMask`, `google.protobuf.Timestamp`, and +// `google.protobuf.Duration` — including the predefined-rule path, which binds +// `this` to the field value via `AsCelValue`. +// +// `FieldMask` is exposed as a CEL map with one entry, `paths`, so expressions +// like `this.paths.all(p, ...)` work. `Timestamp` and `Duration` are exposed +// as native CEL `Timestamp` / `Duration` values, which carry their own +// comparison and arithmetic operators in CEL. + +impl AsCelValue for buffa_types::google::protobuf::FieldMask { + fn as_cel_value(&self) -> Value { + let paths: Vec = self + .paths + .iter() + .map(|p| Value::String(Arc::new(p.clone()))) + .collect(); + let mut map: std::collections::HashMap = + std::collections::HashMap::with_capacity(1); + map.insert( + crate::cel_core::objects::Key::String(Arc::new("paths".to_string())), + Value::List(Arc::new(paths)), + ); + Value::Map(map.into()) + } +} + +impl AsCelValue for buffa_types::google::protobuf::Timestamp { + fn as_cel_value(&self) -> Value { + Value::Timestamp(timestamp_from_secs_nanos(self.seconds, self.nanos)) + } +} + +impl AsCelValue for buffa_types::google::protobuf::Duration { + fn as_cel_value(&self) -> Value { + Value::Duration(duration_from_secs_nanos(self.seconds, self.nanos)) + } +} + impl ToCelValue for buffa::EnumValue { fn to_cel_value(&self) -> Value { Value::Int(i64::from(self.to_i32())) diff --git a/crates/protovalidate-buffa/tests/wkt_cel.rs b/crates/protovalidate-buffa/tests/wkt_cel.rs new file mode 100644 index 0000000..eaa037c --- /dev/null +++ b/crates/protovalidate-buffa/tests/wkt_cel.rs @@ -0,0 +1,139 @@ +//! `AsCelValue` impls for `google.protobuf.FieldMask`, `Timestamp`, and +//! `Duration`. Each test compiles and runs a real CEL expression against the +//! WKT, exercising the same `CelConstraint::eval` path used by +//! plugin-emitted field-level predefined rules. + +use buffa_types::google::protobuf::{Duration, FieldMask, Timestamp}; +use protovalidate_buffa::cel::CelConstraint; + +#[test] +fn field_mask_paths_all_true() { + static RULE: CelConstraint = CelConstraint::new( + "test.field_mask.paths_all", + "every path must be 'display_name' or 'icon'", + "this.paths.all(p, p == 'display_name' || p == 'icon')", + ); + + let mask = FieldMask { + paths: vec!["display_name".to_string(), "icon".to_string()], + ..FieldMask::default() + }; + + RULE.eval(&mask).expect("rule should pass"); +} + +#[test] +fn field_mask_paths_all_false() { + static RULE: CelConstraint = CelConstraint::new( + "test.field_mask.paths_all", + "path not allowed", + "this.paths.all(p, p == 'display_name')", + ); + + let mask = FieldMask { + paths: vec!["display_name".to_string(), "icon".to_string()], + ..FieldMask::default() + }; + + let err = RULE.eval(&mask).expect_err("rule should reject 'icon'"); + assert_eq!(err.rule_id, "test.field_mask.paths_all"); +} + +#[test] +fn field_mask_size_zero_empty() { + static RULE: CelConstraint = CelConstraint::new( + "test.field_mask.non_empty", + "field mask must not be empty", + "size(this.paths) > 0", + ); + + let empty = FieldMask::default(); + RULE.eval(&empty).expect_err("empty mask should fail"); + + let nonempty = FieldMask { + paths: vec!["x".to_string()], + ..FieldMask::default() + }; + RULE.eval(&nonempty).expect("non-empty mask should pass"); +} + +#[test] +fn duration_compare_positive() { + static RULE: CelConstraint = CelConstraint::new( + "test.duration.positive", + "must be greater than zero", + "this > duration('0s')", + ); + + let positive = Duration { + seconds: 5, + nanos: 0, + ..Duration::default() + }; + RULE.eval(&positive).expect("positive duration passes"); + + let zero = Duration::default(); + RULE.eval(&zero).expect_err("zero duration fails"); +} + +#[test] +fn duration_nanos_resolution() { + static RULE: CelConstraint = CelConstraint::new( + "test.duration.sub_second", + "must be at least 1ms", + "this >= duration('0.001s')", + ); + + let one_ms = Duration { + seconds: 0, + nanos: 1_000_000, + ..Duration::default() + }; + RULE.eval(&one_ms).expect("1ms passes"); + + let half_ms = Duration { + seconds: 0, + nanos: 500_000, + ..Duration::default() + }; + RULE.eval(&half_ms).expect_err("0.5ms fails"); +} + +#[test] +fn timestamp_compare_before_max() { + static RULE: CelConstraint = CelConstraint::new( + "test.timestamp.before_max", + "must be before 9999-12-31", + "this < timestamp('9999-12-31T23:59:59Z')", + ); + + let ts = Timestamp { + seconds: 1_700_000_000, + nanos: 0, + ..Timestamp::default() + }; + RULE.eval(&ts).expect("normal timestamp passes"); +} + +#[test] +fn timestamp_in_past() { + static RULE: CelConstraint = CelConstraint::new( + "test.timestamp.in_past", + "must be in the past", + "this < now", + ); + + let past = Timestamp { + seconds: 1_000_000_000, + nanos: 0, + ..Timestamp::default() + }; + RULE.eval(&past).expect("past timestamp passes"); + + let future = Timestamp { + seconds: 99_999_999_999, + nanos: 0, + ..Timestamp::default() + }; + RULE.eval(&future).expect_err("future timestamp fails"); +}