Skip to content

Commit

Permalink
feat: Adds operations for datetime and span policy expression primitives
Browse files Browse the repository at this point in the history
  • Loading branch information
mchernicoff committed Sep 24, 2024
1 parent e7d1a86 commit 87c312b
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 12 deletions.
99 changes: 92 additions & 7 deletions hipcheck/src/policy_exprs/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -325,6 +328,13 @@ fn gt(env: &Env, args: &[Expr]) -> Result<Expr> {
(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!(),
};

Expand All @@ -339,6 +349,13 @@ fn lt(env: &Env, args: &[Expr]) -> Result<Expr> {
(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!(),
};

Expand All @@ -353,6 +370,13 @@ fn gte(env: &Env, args: &[Expr]) -> Result<Expr> {
(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!(),
};

Expand All @@ -367,6 +391,13 @@ fn lte(env: &Env, args: &[Expr]) -> Result<Expr> {
(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!(),
};

Expand All @@ -381,6 +412,13 @@ fn eq(env: &Env, args: &[Expr]) -> Result<Expr> {
(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!(),
};

Expand All @@ -395,32 +433,64 @@ fn neq(env: &Env, args: &[Expr]) -> Result<Expr> {
(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<Expr> {
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<Expr> {
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!(),
};

Expand All @@ -445,7 +515,24 @@ fn divz(env: &Env, args: &[Expr]) -> Result<Expr> {
} 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<Expr> {
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!(),
};

Expand All @@ -456,9 +543,8 @@ fn and(env: &Env, args: &[Expr]) -> Result<Expr> {
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!(),
};

Expand All @@ -469,9 +555,8 @@ fn or(env: &Env, args: &[Expr]) -> Result<Expr> {
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!(),
};

Expand Down
3 changes: 3 additions & 0 deletions hipcheck/src/policy_exprs/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ pub enum Error {
value: serde_json::Value,
context: serde_json::Value,
},

#[error("Datetime error: {0}")]
Datetime(String),
}

#[derive(Debug, PartialEq)]
Expand Down
50 changes: 47 additions & 3 deletions hipcheck/src/policy_exprs/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand Down Expand Up @@ -238,8 +245,14 @@ pub fn parse(input: &str) -> Result<Expr> {
#[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;
}
Expand Down Expand Up @@ -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<Primitive>) -> Expr {
Expr::Array(vals)
}
Expand All @@ -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)";
Expand Down
35 changes: 33 additions & 2 deletions hipcheck/src/policy_exprs/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,29 @@ fn lex_span(input: &mut Lexer<'_, Token>) -> Result<Box<Span>> {
let s = input.slice();
s.parse::<Span>()
.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<Span> {
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<String> {
Ok(input.slice().to_owned())
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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() {
Expand Down

0 comments on commit 87c312b

Please sign in to comment.