regorus 0.10.1

A fast, lightweight Rego (OPA policy language) interpreter
Documentation
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//! Custom recursive-descent JSON parser for Azure Policy rules.
//!
//! Parses Azure Policy JSON directly from [`Lexer`] tokens, building span-annotated
//! AST nodes in a single pass. No intermediate `serde_json::Value` is created.
//!
//! The parser is policy-aware: when parsing JSON objects, it dispatches on key names
//! (`allOf`, `anyOf`, `not`, `field`, `value`, `count`, operator names) to build
//! the appropriate AST nodes.
//!
//! ## Usage
//!
//! ```ignore
//! use regorus::Source;
//! use regorus::languages::azure_policy::parser;
//!
//! let json = r#"{ "if": { "field": "type", "equals": "Microsoft.Compute/virtualMachines" },
//!                 "then": { "effect": "deny" } }"#;
//! let source = Source::from_contents("policy.json".into(), json.into())?;
//! let rule = parser::parse_policy_rule(&source)?;
//! ```

mod constraint;
mod core;
mod error;
mod policy_definition;
mod policy_rule;

pub(super) use self::core::json_unescape;

pub use error::ParseError;

use alloc::string::ToString as _;

use ::core::num::NonZeroU32;

use crate::lexer::{Source, TokenKind};

use super::ast::{Constraint, FieldKind, OperatorKind, PolicyDefinition, PolicyRule};
use super::expr::ExprParser;

use self::core::Parser;

/// Column-width limit for Azure Policy definitions.
///
/// Azure Policy definitions are often serialized as single-line JSON with
/// deeply nested template expressions, requiring a much higher limit than
/// the standard Rego default (1024).
pub const MAX_COL: u32 = Parser::MAX_COL;

// ============================================================================
// Public API
// ============================================================================

/// Parse an Azure Policy rule from a JSON source.
///
/// The source should contain a complete `policyRule` JSON object:
/// ```json
/// {
///   "if": { "field": "type", "equals": "Microsoft.Compute/virtualMachines" },
///   "then": { "effect": "deny" }
/// }
/// ```
///
/// Returns a span-annotated [`PolicyRule`] AST.
pub fn parse_policy_rule(source: &Source) -> Result<PolicyRule, ParseError> {
    parse_policy_rule_with_max_col(source, None)
}

/// Like [`parse_policy_rule`] but with an explicit column-width override.
///
/// When `max_col` is `None`, uses [`MAX_COL`] (the Azure Policy default).
pub fn parse_policy_rule_with_max_col(
    source: &Source,
    max_col: Option<NonZeroU32>,
) -> Result<PolicyRule, ParseError> {
    let mut parser = Parser::new_with_max_col(source, max_col.or(NonZeroU32::new(MAX_COL)))?;
    let rule = parser.parse_policy_rule()?;

    if parser.tok.0 != TokenKind::Eof {
        return Err(ParseError::UnexpectedToken {
            span: parser.tok.1.clone(),
            expected: "end of input",
        });
    }

    Ok(rule)
}

/// Parse a full Azure Policy definition from a JSON source.
///
/// Accepts two forms:
/// 1. **Wrapped**: `{ "properties": { "policyRule": ..., ... }, "id": ..., ... }`
/// 2. **Unwrapped**: `{ "displayName": ..., "policyRule": ..., ... }`
///
/// Returns a [`PolicyDefinition`] with typed fields for known properties
/// and a catch-all list of `extra` entries for everything else.
pub fn parse_policy_definition(source: &Source) -> Result<PolicyDefinition, ParseError> {
    parse_policy_definition_with_max_col(source, None)
}

/// Like [`parse_policy_definition`] but with an explicit column-width override.
///
/// When `max_col` is `None`, uses [`MAX_COL`] (the Azure Policy default).
pub fn parse_policy_definition_with_max_col(
    source: &Source,
    max_col: Option<NonZeroU32>,
) -> Result<PolicyDefinition, ParseError> {
    let mut parser = Parser::new_with_max_col(source, max_col.or(NonZeroU32::new(MAX_COL)))?;
    let defn = parser.parse_policy_definition()?;

    if parser.tok.0 != TokenKind::Eof {
        return Err(ParseError::UnexpectedToken {
            span: parser.tok.1.clone(),
            expected: "end of input",
        });
    }

    Ok(defn)
}

/// Parse a standalone constraint from a JSON source.
///
/// Useful for parsing just the `"if"` part of a policy rule.
pub fn parse_constraint(source: &Source) -> Result<Constraint, ParseError> {
    let mut parser = Parser::new(source)?;
    let constraint = parser.parse_constraint()?;

    if parser.tok.0 != TokenKind::Eof {
        return Err(ParseError::UnexpectedToken {
            span: parser.tok.1.clone(),
            expected: "end of input",
        });
    }

    Ok(constraint)
}

// ============================================================================
// Helper functions (used across submodules)
// ============================================================================

/// Check if a string is an ARM template expression (`[...]` but not `[[...`).
pub(super) fn is_template_expr(s: &str) -> bool {
    s.starts_with('[') && s.ends_with(']') && !s.starts_with("[[")
}

/// Checked `s[prefix_len..]`.
///
/// Callers guarantee bounds via a prior `starts_with` check on an ASCII
/// prefix whose byte-length equals `prefix_len`.
fn tail(s: &str, prefix_len: usize) -> &str {
    s.get(prefix_len..).unwrap_or_default()
}

/// Checked `s[prefix_len .. s.len() - suffix_len]`.
///
/// Callers guarantee bounds via prior `starts_with` / `ends_with` checks.
fn unwrap(s: &str, prefix_len: usize, suffix_len: usize) -> &str {
    let end = s.len().saturating_sub(suffix_len);
    s.get(prefix_len..end).unwrap_or_default()
}

/// Classify a field string into a [`FieldKind`].
pub(super) fn classify_field(
    text: &str,
    span: &crate::lexer::Span,
) -> Result<FieldKind, ParseError> {
    let lower = text.to_lowercase();
    match lower.as_str() {
        "type" => Ok(FieldKind::Type),
        "id" => Ok(FieldKind::Id),
        "kind" => Ok(FieldKind::Kind),
        "name" => Ok(FieldKind::Name),
        "location" => Ok(FieldKind::Location),
        "fullname" => Ok(FieldKind::FullName),
        "tags" => Ok(FieldKind::Tags),
        "identity.type" => Ok(FieldKind::IdentityType),
        _ if lower.starts_with("identity.") => Ok(FieldKind::IdentityField(
            tail(text, "identity.".len()).into(),
        )),
        "apiversion" => Ok(FieldKind::ApiVersion),
        _ if lower.starts_with("tags.") => Ok(FieldKind::Tag(tail(text, "tags.".len()).into())),
        _ if lower.starts_with("tags['") && text.ends_with("']") => Ok(FieldKind::Tag(
            unwrap(text, "tags['".len(), "']".len()).into(),
        )),
        // Tags[tagName] — bracket notation without quotes.
        // Tag name is everything between the first '[' and the LAST ']'.
        // e.g. Tags[Dept.Name]] → tag name "Dept.Name]"
        // Exclude tags[*] which is a wildcard iteration, not a tag name.
        _ if lower.starts_with("tags[") && text.ends_with(']') && !text.contains("[*]") => Ok(
            FieldKind::Tag(unwrap(text, "tags[".len(), "]".len()).into()),
        ),
        _ if is_template_expr(text) => {
            let inner = unwrap(text, 1, 1);
            let expr = ExprParser::parse_from_brackets(inner, span).map_err(|e| {
                ParseError::ExprParse {
                    span: span.clone(),
                    message: e.to_string(),
                }
            })?;
            Ok(FieldKind::Expr(expr))
        }
        _ => Ok(FieldKind::Alias(text.into())),
    }
}

/// Try to parse a lowercase key as an operator kind.
pub(super) fn parse_operator_kind(key: &str) -> Option<OperatorKind> {
    match key {
        "contains" => Some(OperatorKind::Contains),
        "containskey" => Some(OperatorKind::ContainsKey),
        "equals" => Some(OperatorKind::Equals),
        "greater" => Some(OperatorKind::Greater),
        "greaterorequals" => Some(OperatorKind::GreaterOrEquals),
        "exists" => Some(OperatorKind::Exists),
        "in" => Some(OperatorKind::In),
        "less" => Some(OperatorKind::Less),
        "lessorequals" => Some(OperatorKind::LessOrEquals),
        "like" => Some(OperatorKind::Like),
        "match" => Some(OperatorKind::Match),
        "matchinsensitively" => Some(OperatorKind::MatchInsensitively),
        "notcontains" => Some(OperatorKind::NotContains),
        "notcontainskey" => Some(OperatorKind::NotContainsKey),
        "notequals" => Some(OperatorKind::NotEquals),
        "notin" => Some(OperatorKind::NotIn),
        "notlike" => Some(OperatorKind::NotLike),
        "notmatch" => Some(OperatorKind::NotMatch),
        "notmatchinsensitively" => Some(OperatorKind::NotMatchInsensitively),
        _ => None,
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;

    fn dummy_span() -> crate::lexer::Span {
        let source =
            crate::lexer::Source::from_contents("<test>".into(), alloc::string::String::new())
                .unwrap();
        crate::lexer::Span {
            source,
            line: 1,
            col: 1,
            start: 0,
            end: 0,
        }
    }

    #[test]
    fn test_is_template_expr() {
        assert!(is_template_expr("[field('type')]"));
        assert!(is_template_expr("[concat('a', 'b')]"));
        assert!(!is_template_expr("[[escaped]"));
        assert!(!is_template_expr("not-an-expr"));
        assert!(!is_template_expr("[no-end-bracket"));
    }

    #[test]
    fn test_classify_field_builtin_names() {
        let span = dummy_span();
        assert!(matches!(classify_field("type", &span), Ok(FieldKind::Type)));
        assert!(matches!(classify_field("Type", &span), Ok(FieldKind::Type)));
        assert!(matches!(classify_field("name", &span), Ok(FieldKind::Name)));
        assert!(matches!(
            classify_field("location", &span),
            Ok(FieldKind::Location)
        ));
    }

    #[test]
    fn test_classify_field_alias() {
        let span = dummy_span();
        match classify_field("Microsoft.Compute/virtualMachines/storageProfile", &span) {
            Ok(FieldKind::Alias(a)) => {
                assert_eq!(a, "Microsoft.Compute/virtualMachines/storageProfile");
            }
            other => panic!("expected Alias, got {:?}", other),
        }
    }

    #[test]
    fn test_classify_field_expression() {
        let span = dummy_span();
        match classify_field("[field('type')]", &span) {
            Ok(FieldKind::Expr(_)) => {}
            other => panic!("expected Expr, got {:?}", other),
        }
    }

    #[test]
    fn test_classify_field_double_bracket_not_expr() {
        // [[escaped] should NOT be parsed as an expression.
        let span = dummy_span();
        match classify_field("[[escaped]", &span) {
            Ok(FieldKind::Alias(a)) => assert_eq!(a, "[[escaped]"),
            other => panic!("expected Alias, got {:?}", other),
        }
    }
}