diff --git a/hipcheck/src/policy_exprs/env.rs b/hipcheck/src/policy_exprs/env.rs index f16d7c88..f8f10835 100644 --- a/hipcheck/src/policy_exprs/env.rs +++ b/hipcheck/src/policy_exprs/env.rs @@ -55,6 +55,9 @@ impl<'parent> Env<'parent> { env.add_fn("sub", sub); env.add_fn("divz", divz); + // Additional datetime math functions + env.add_fn("duration", duration); + // Logical functions. env.add_fn("and", and); env.add_fn("or", or); @@ -325,6 +328,13 @@ fn gt(env: &Env, args: &[Expr]) -> Result { (Int(arg_1), Int(arg_2)) => Ok(Bool(arg_1 > arg_2)), (Float(arg_1), Float(arg_2)) => Ok(Bool(arg_1 > arg_2)), (Bool(arg_1), Bool(arg_2)) => Ok(Bool(arg_1 > arg_2)), + (DateTime(arg_1), DateTime(arg_2)) => Ok(Bool(arg_1 > arg_2)), + (Span(arg_1), Span(arg_2)) => Ok(Bool( + arg_1 + .compare(arg_2) + .map_err(|err| Error::Datetime(err.to_string()))? + .is_gt(), + )), _ => unreachable!(), }; @@ -339,6 +349,13 @@ fn lt(env: &Env, args: &[Expr]) -> Result { (Int(arg_1), Int(arg_2)) => Ok(Bool(arg_1 < arg_2)), (Float(arg_1), Float(arg_2)) => Ok(Bool(arg_1 < arg_2)), (Bool(arg_1), Bool(arg_2)) => Ok(Bool(arg_1 < arg_2)), + (DateTime(arg_1), DateTime(arg_2)) => Ok(Bool(arg_1 < arg_2)), + (Span(arg_1), Span(arg_2)) => Ok(Bool( + arg_1 + .compare(arg_2) + .map_err(|err| Error::Datetime(err.to_string()))? + .is_lt(), + )), _ => unreachable!(), }; @@ -353,6 +370,13 @@ fn gte(env: &Env, args: &[Expr]) -> Result { (Int(arg_1), Int(arg_2)) => Ok(Bool(arg_1 >= arg_2)), (Float(arg_1), Float(arg_2)) => Ok(Bool(arg_1 >= arg_2)), (Bool(arg_1), Bool(arg_2)) => Ok(Bool(arg_1 >= arg_2)), + (DateTime(arg_1), DateTime(arg_2)) => Ok(Bool(arg_1 >= arg_2)), + (Span(arg_1), Span(arg_2)) => Ok(Bool( + arg_1 + .compare(arg_2) + .map_err(|err| Error::Datetime(err.to_string()))? + .is_ge(), + )), _ => unreachable!(), }; @@ -367,6 +391,13 @@ fn lte(env: &Env, args: &[Expr]) -> Result { (Int(arg_1), Int(arg_2)) => Ok(Bool(arg_1 <= arg_2)), (Float(arg_1), Float(arg_2)) => Ok(Bool(arg_1 <= arg_2)), (Bool(arg_1), Bool(arg_2)) => Ok(Bool(arg_1 <= arg_2)), + (DateTime(arg_1), DateTime(arg_2)) => Ok(Bool(arg_1 <= arg_2)), + (Span(arg_1), Span(arg_2)) => Ok(Bool( + arg_1 + .compare(arg_2) + .map_err(|err| Error::Datetime(err.to_string()))? + .is_le(), + )), _ => unreachable!(), }; @@ -381,6 +412,13 @@ fn eq(env: &Env, args: &[Expr]) -> Result { (Int(arg_1), Int(arg_2)) => Ok(Bool(arg_1 == arg_2)), (Float(arg_1), Float(arg_2)) => Ok(Bool(arg_1 == arg_2)), (Bool(arg_1), Bool(arg_2)) => Ok(Bool(arg_1 == arg_2)), + (DateTime(arg_1), DateTime(arg_2)) => Ok(Bool(arg_1 == arg_2)), + (Span(arg_1), Span(arg_2)) => Ok(Bool( + arg_1 + .compare(arg_2) + .map_err(|err| Error::Datetime(err.to_string()))? + .is_eq(), + )), _ => unreachable!(), }; @@ -395,32 +433,64 @@ fn neq(env: &Env, args: &[Expr]) -> Result { (Int(arg_1), Int(arg_2)) => Ok(Bool(arg_1 != arg_2)), (Float(arg_1), Float(arg_2)) => Ok(Bool(arg_1 != arg_2)), (Bool(arg_1), Bool(arg_2)) => Ok(Bool(arg_1 != arg_2)), + (DateTime(arg_1), DateTime(arg_2)) => Ok(Bool(arg_1 != arg_2)), + (Span(arg_1), Span(arg_2)) => Ok(Bool( + arg_1 + .compare(arg_2) + .map_err(|err| Error::Datetime(err.to_string()))? + .is_ne(), + )), _ => unreachable!(), }; binary_primitive_op(name, env, args, op) } +// Adds numbers or adds a span of time to a datetime (the latter use case is *not* commutative) +// Datetime addition will error for spans with units greater than days (which the parser should prevent) fn add(env: &Env, args: &[Expr]) -> Result { let name = "add"; let op = |arg_1, arg_2| match (arg_1, arg_2) { (Int(arg_1), Int(arg_2)) => Ok(Int(arg_1 + arg_2)), (Float(arg_1), Float(arg_2)) => Ok(Float(arg_1 + arg_2)), - (Bool(_), Bool(_)) => Err(Error::BadType(name)), + (DateTime(arg_1), Span(arg_2)) => Ok(DateTime( + arg_1 + .checked_add(arg_2) + .map_err(|err| Error::Datetime(err.to_string()))?, + )), + (Span(arg_1), Span(arg_2)) => Ok(Span( + arg_1 + .checked_add(arg_2) + .map_err(|err| Error::Datetime(err.to_string()))?, + )), + (_, _) => Err(Error::BadType(name)), _ => unreachable!(), }; binary_primitive_op(name, env, args, op) } +// Subtracts numbers or subtracts a span of time from a datetime +// Datetime addition will error for spans with units greater than days (which the parser should prevent) +// Do not use for finding the difference between two dateimes. The correct operation for "subtracting" two datetimes is "duration." fn sub(env: &Env, args: &[Expr]) -> Result { let name = "sub"; let op = |arg_1, arg_2| match (arg_1, arg_2) { (Int(arg_1), Int(arg_2)) => Ok(Int(arg_1 - arg_2)), (Float(arg_1), Float(arg_2)) => Ok(Float(arg_1 - arg_2)), - (Bool(_), Bool(_)) => Err(Error::BadType(name)), + (DateTime(arg_1), Span(arg_2)) => Ok(DateTime( + arg_1 + .checked_sub(arg_2) + .map_err(|err| Error::Datetime(err.to_string()))?, + )), + (Span(arg_1), Span(arg_2)) => Ok(Span( + arg_1 + .checked_sub(arg_2) + .map_err(|err| Error::Datetime(err.to_string()))?, + )), + (_, _) => Err(Error::BadType(name)), _ => unreachable!(), }; @@ -445,7 +515,24 @@ fn divz(env: &Env, args: &[Expr]) -> Result { } else { Float(arg_1 / arg_2) }), - (Bool(_), Bool(_)) => Err(Error::BadType(name)), + (_, _) => Err(Error::BadType(name)), + _ => unreachable!(), + }; + + binary_primitive_op(name, env, args, op) +} + +// Finds the difference in time between two datetimes, in units of hours (chosen for comparision safety) +fn duration(env: &Env, args: &[Expr]) -> Result { + let name = "duration"; + + let op = |arg_1, arg_2| match (arg_1, arg_2) { + (DateTime(arg_1), DateTime(arg_2)) => Ok(Span( + arg_1 + .since(&arg_2) + .map_err(|err| Error::Datetime(err.to_string()))?, + )), + (_, _) => Err(Error::BadType(name)), _ => unreachable!(), }; @@ -456,9 +543,8 @@ fn and(env: &Env, args: &[Expr]) -> Result { let name = "and"; let op = |arg_1, arg_2| match (arg_1, arg_2) { - (Int(_), Int(_)) => Err(Error::BadType(name)), - (Float(_), Float(_)) => Err(Error::BadType(name)), (Bool(arg_1), Bool(arg_2)) => Ok(Bool(arg_1 && arg_2)), + (_, _) => Err(Error::BadType(name)), _ => unreachable!(), }; @@ -469,9 +555,8 @@ fn or(env: &Env, args: &[Expr]) -> Result { let name = "or"; let op = |arg_1, arg_2| match (arg_1, arg_2) { - (Int(_), Int(_)) => Err(Error::BadType(name)), - (Float(_), Float(_)) => Err(Error::BadType(name)), (Bool(arg_1), Bool(arg_2)) => Ok(Bool(arg_1 || arg_2)), + (_, _) => Err(Error::BadType(name)), _ => unreachable!(), }; diff --git a/hipcheck/src/policy_exprs/error.rs b/hipcheck/src/policy_exprs/error.rs index 61602192..387aca53 100644 --- a/hipcheck/src/policy_exprs/error.rs +++ b/hipcheck/src/policy_exprs/error.rs @@ -118,6 +118,9 @@ pub enum Error { value: serde_json::Value, context: serde_json::Value, }, + + #[error("Datetime error: {0}")] + Datetime(String), } #[derive(Debug, PartialEq)] diff --git a/hipcheck/src/policy_exprs/expr.rs b/hipcheck/src/policy_exprs/expr.rs index 1e175a30..bde4a575 100644 --- a/hipcheck/src/policy_exprs/expr.rs +++ b/hipcheck/src/policy_exprs/expr.rs @@ -59,11 +59,18 @@ pub enum Primitive { DateTime(Zoned), /// Span of time using the [jiff] crate, which uses a modified version of ISO8601. - /// Can include years, months, weeks, days, hours, minutes, and seconds (including decimal fractions of a second). + /// + /// Can include weeks, days, hours, minutes, and seconds (including decimal fractions of a second). + /// While spans with months, years, or both are valid under IS08601 and supported by [jiff] in general, we do not allow them in Hipcheck policy expressions. + /// This is because spans greater than a day require additional zoned datetime information in [jiff] (to determine e.g. how many days are in a year or month) + /// before we can do time arithematic with them. + /// We *do* allows spans with weeks, even though [jiff] has similar issues with those units. + /// We take care of this by converting a week to a period of seven 24-hour days that [jiff] can handle in arithematic without zoned datetime information. + /// /// Spans are preceded by the letter "P" with any optional time units separated from optional date units by the letter "T". /// All units of dates and times are represented by single case-agnostic letter abbreviations after the number. - /// For example, a span of one year, one month, one week, one day, one hour, one minute, and one-and-a-tenth seconds would be represented as - /// "P1y1m1w1dT1h1m1.1s" + /// For example, a span of one week, one day, one hour, one minute, and one-and-a-tenth seconds would be represented as + /// "P1w1dT1h1m1.1s" Span(Span), } @@ -238,8 +245,14 @@ pub fn parse(input: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::policy_exprs::LexingError; use test_log::test; + use jiff::{ + tz::{self, TimeZone}, + Span, Timestamp, Zoned, + }; + trait IntoExpr { fn into_expr(self) -> Expr; } @@ -273,6 +286,14 @@ mod tests { Primitive::Bool(val) } + fn datetime(val: Zoned) -> Primitive { + Primitive::DateTime(val) + } + + fn span(val: Span) -> Primitive { + Primitive::Span(val) + } + fn array(vals: Vec) -> Expr { Expr::Array(vals) } @@ -292,6 +313,29 @@ mod tests { assert_eq!(result, expected); } + #[test] + fn parse_datetime() { + let input = "2024-09-17T09:30:00-05"; + let result = parse(input).unwrap(); + + let ts: Timestamp = "2024-09-17T09:30:00-05".parse().unwrap(); + let dt = Zoned::new(ts, TimeZone::UTC); + let expected = datetime(dt).into_expr(); + + assert_eq!(result, expected); + } + + #[test] + fn parse_span() { + let input = "P2W4DT1H30M"; + let result = parse(input).unwrap(); + + let raw_span: Span = "P18DT1H30M".parse().unwrap(); + let expected = span(raw_span).into_expr(); + + assert_eq!(result, expected); + } + #[test] fn parse_function() { let input = "(add 2 3)"; diff --git a/hipcheck/src/policy_exprs/token.rs b/hipcheck/src/policy_exprs/token.rs index d7f98d29..2cfb296d 100644 --- a/hipcheck/src/policy_exprs/token.rs +++ b/hipcheck/src/policy_exprs/token.rs @@ -117,9 +117,29 @@ fn lex_span(input: &mut Lexer<'_, Token>) -> Result> { let s = input.slice(); s.parse::() .map_err(|err| LexingError::InvalidSpan(s.to_string(), JiffError::new(err))) + .map(|x| span_to_days(&x))? .map(Box::new) } +// Error if the span contains years or months. +// If the span contains weeks, convert the weeks to days by treating every week as 7 24-hour days. +fn span_to_days(full_span: &Span) -> Result { + if full_span.get_years() != 0 || full_span.get_months() != 0 { + Err(LexingError::SpanWithBadUnits) + } else { + let weeks = full_span.get_weeks(); + let days = full_span.get_days(); + let total_days = weeks * 7 + days; + + // Panic: The unwrap() on try_weeks will not panic when the argument is 0. + full_span + .try_weeks(0) + .unwrap() + .try_days(total_days) + .map_err(|err| LexingError::InvalidSpan(full_span.to_string(), JiffError::new(err))) + } +} + /// Lex a single identifier. fn lex_ident(input: &mut Lexer<'_, Token>) -> Result { Ok(input.slice().to_owned()) @@ -192,6 +212,9 @@ pub enum LexingError { #[error("failed to parse span")] InvalidSpan(String, JiffError), + + #[error("span cannot contain units of years or months")] + SpanWithBadUnits, } #[cfg(test)] @@ -301,7 +324,7 @@ mod tests { #[test] fn basic_lexing_with_time() { - let raw_program = "(eq (sub 2024-09-17T09:00-05 2024-09-17T10:30-05) PT1H30M)"; + let raw_program = "(eq (duration 2024-09-17T09:00-05 2024-09-17T10:30-05) PT1H30M)"; let ts1: Timestamp = "2024-09-17T09:00-05".parse().unwrap(); let dt1 = Zoned::new(ts1, TimeZone::UTC); @@ -313,7 +336,7 @@ mod tests { Token::OpenParen, Token::Ident(String::from("eq")), Token::OpenParen, - Token::Ident(String::from("sub")), + Token::Ident(String::from("duration")), Token::DateTime(Box::new(dt1)), Token::DateTime(Box::new(dt2)), Token::CloseParen, @@ -325,6 +348,14 @@ mod tests { assert_eq!(tokens, expected); } + #[test] + fn lexing_with_bad_span() { + let raw_program = "P4M2W4DT1H30M"; + let expected = Err(Lex(LexingError::SpanWithBadUnits)); + let tokens = lex(raw_program); + assert_eq!(tokens, expected); + } + // Ensure that idents with capital P are prioritized over being treated as spans #[test] fn regression_lex_span_and_ident() {