online-dsl-forge 0.3.3

A bounded parser and runtime for in-memory DSL expressions.
Documentation
use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};

use crate::rulepack_render::error::{RenderResult, fail};
use crate::rulepack_render::types::{
  RenderedRulepackFile, RulepackDocument, RulepackGroupFile, RulepackReferencedFile,
  RulepackReferencedFileKind, RulepackRule,
};

pub trait FileResolver {
  fn resolve_file(&self, file: &RulepackReferencedFile) -> RenderResult<String>;
}

#[derive(Debug, Clone, Default)]
pub struct MemoryFileResolver {
  files: BTreeMap<String, String>,
}

impl MemoryFileResolver {
  pub fn new() -> Self {
    Self::default()
  }

  pub fn insert(&mut self, path: impl Into<String>, content: impl Into<String>) -> Option<String> {
    self.files.insert(path.into(), content.into())
  }

  pub fn with_file(mut self, path: impl Into<String>, content: impl Into<String>) -> Self {
    self.insert(path, content);
    self
  }
}

impl FileResolver for MemoryFileResolver {
  fn resolve_file(&self, file: &RulepackReferencedFile) -> RenderResult<String> {
    let key = logical_path_key(&file.path)?;
    self.files.get(&key).cloned().ok_or_else(|| {
      crate::rulepack_render::RulepackRenderError::new(format!(
        "referenced rulepack file {key} is missing"
      ))
    })
  }
}

#[derive(Debug, Clone, Default)]
pub struct BlobStore {
  blobs: BTreeMap<String, String>,
}

impl BlobStore {
  pub fn new() -> Self {
    Self::default()
  }

  pub fn insert(&mut self, id: impl Into<String>, content: impl Into<String>) -> Option<String> {
    self.blobs.insert(id.into(), content.into())
  }

  pub fn get(&self, id: &str) -> Option<&str> {
    self.blobs.get(id).map(String::as_str)
  }
}

#[derive(Debug, Clone, Default)]
pub struct BlobFileResolver {
  blobs: BlobStore,
  path_to_blob: BTreeMap<String, String>,
}

impl BlobFileResolver {
  pub fn new(blobs: BlobStore) -> Self {
    Self {
      blobs,
      path_to_blob: BTreeMap::new(),
    }
  }

  pub fn insert_mapping(
    &mut self,
    path: impl Into<String>,
    blob_id: impl Into<String>,
  ) -> Option<String> {
    self.path_to_blob.insert(path.into(), blob_id.into())
  }

  pub fn with_mapping(mut self, path: impl Into<String>, blob_id: impl Into<String>) -> Self {
    self.insert_mapping(path, blob_id);
    self
  }
}

impl FileResolver for BlobFileResolver {
  fn resolve_file(&self, file: &RulepackReferencedFile) -> RenderResult<String> {
    let key = logical_path_key(&file.path)?;
    let blob_id = self.path_to_blob.get(&key).ok_or_else(|| {
      crate::rulepack_render::RulepackRenderError::new(format!(
        "referenced rulepack file {key} has no blob mapping"
      ))
    })?;
    self.blobs.get(blob_id).map(str::to_string).ok_or_else(|| {
      crate::rulepack_render::RulepackRenderError::new(format!(
        "referenced rulepack file {key} maps to missing blob {blob_id}"
      ))
    })
  }
}

pub(crate) fn referenced_rulepack_files(
  document: &RulepackDocument,
) -> RenderResult<Vec<RulepackReferencedFile>> {
  let mut files = Vec::new();
  for rule in &document.rules {
    if let Some(path) = &rule.path {
      validate_relative_rulepack_path(
        &format!("rulepack {} rule {}", document.rulepack.name, rule.name),
        path,
        ".oxirule.toml",
      )?;
      files.push(RulepackReferencedFile {
        kind: RulepackReferencedFileKind::Rule,
        path: path.clone(),
      });
    }
  }
  for group_file in &document.group_files {
    if let Some(path) = &group_file.path {
      validate_relative_rulepack_path(
        &format!("rulepack {} group file", document.rulepack.name),
        path,
        ".oxirule-group.toml",
      )?;
      files.push(RulepackReferencedFile {
        kind: RulepackReferencedFileKind::Group,
        path: path.clone(),
      });
    }
  }
  Ok(files)
}

pub(crate) fn validate_rule_content_or_path(label: &str, rule: &RulepackRule) -> RenderResult<()> {
  validate_content_or_path(
    label,
    rule.content.as_deref(),
    rule.path.as_deref(),
    ".oxirule.toml",
  )
}

pub(crate) fn validate_group_content_or_path(
  label: &str,
  group_file: &RulepackGroupFile,
) -> RenderResult<()> {
  validate_content_or_path(
    label,
    group_file.content.as_deref(),
    group_file.path.as_deref(),
    ".oxirule-group.toml",
  )
}

pub(crate) fn embedded_or_resolved_file<R: FileResolver + ?Sized>(
  file: RulepackReferencedFile,
  embedded: Option<&str>,
  resolver: &R,
  variables: &BTreeMap<String, String>,
) -> RenderResult<RenderedRulepackFile> {
  let raw = match embedded {
    Some(content) => content.to_string(),
    None => resolver.resolve_file(&file)?,
  };
  Ok(RenderedRulepackFile {
    kind: file.kind,
    path: file.path,
    content: super::render_text(&raw, variables),
  })
}

fn validate_content_or_path(
  label: &str,
  content: Option<&str>,
  path: Option<&Path>,
  suffix: &str,
) -> RenderResult<()> {
  match (content, path) {
    (Some(_), Some(_)) => fail(format!("{label} must use either content or path, not both")),
    (None, None) => fail(format!("{label} must include content or path")),
    (Some(content), None) => {
      if content.trim().is_empty() {
        return fail(format!("{label} content must not be empty"));
      }
      Ok(())
    }
    (None, Some(path)) => validate_relative_rulepack_path(label, path, suffix),
  }
}

fn validate_relative_rulepack_path(label: &str, path: &Path, suffix: &str) -> RenderResult<()> {
  let value = logical_path_key(path)?;
  if !value.ends_with(suffix) {
    return fail(format!("{label} path must end with {suffix}"));
  }
  Ok(())
}

fn logical_path_key(path: &Path) -> RenderResult<String> {
  let Some(value) = path.to_str() else {
    return fail(format!(
      "rulepack path is not valid UTF-8: {}",
      path.display()
    ));
  };
  if value.trim().is_empty()
    || value.contains('\\')
    || value.bytes().any(|byte| byte.is_ascii_control())
  {
    return fail(format!(
      "rulepack path is not a safe relative path: {value}"
    ));
  }
  if path.is_absolute() {
    return fail(format!("rulepack path must be relative: {value}"));
  }
  let mut components = 0usize;
  for component in path.components() {
    match component {
      Component::Normal(part) if !part.is_empty() => components += 1,
      Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
        return fail(format!(
          "rulepack path is not a safe relative path: {value}"
        ));
      }
      Component::Normal(_) => {
        return fail(format!(
          "rulepack path is not a safe relative path: {value}"
        ));
      }
    }
  }
  if components == 0 || value.split('/').any(str::is_empty) {
    return fail(format!(
      "rulepack path is not a safe relative path: {value}"
    ));
  }
  Ok(value.to_string())
}

#[allow(dead_code)]
fn _pathbuf_from_key(value: &str) -> PathBuf {
  PathBuf::from(value)
}