Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/protovalidate-buffa/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
43 changes: 43 additions & 0 deletions crates/protovalidate-buffa/src/cel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,49 @@ impl<T: AsCelValue + Default> ToCelValue for buffa::MessageField<T> {
}
}

// ---------------------------------------------------------------------------
// 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<Value> = self
.paths
.iter()
.map(|p| Value::String(Arc::new(p.clone())))
.collect();
let mut map: std::collections::HashMap<crate::cel_core::objects::Key, Value> =
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<E: buffa::Enumeration> ToCelValue for buffa::EnumValue<E> {
fn to_cel_value(&self) -> Value {
Value::Int(i64::from(self.to_i32()))
Expand Down
139 changes: 139 additions & 0 deletions crates/protovalidate-buffa/tests/wkt_cel.rs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading