Skip to main content

online_dsl_forge/rulepack_render/
files.rs

1use std::collections::BTreeMap;
2use std::path::{Component, Path, PathBuf};
3
4use crate::rulepack_render::error::{RenderResult, fail};
5use crate::rulepack_render::types::{
6  RenderedRulepackFile, RulepackDocument, RulepackGroupFile, RulepackReferencedFile,
7  RulepackReferencedFileKind, RulepackRule,
8};
9
10pub trait FileResolver {
11  fn resolve_file(&self, file: &RulepackReferencedFile) -> RenderResult<String>;
12}
13
14#[derive(Debug, Clone, Default)]
15pub struct MemoryFileResolver {
16  files: BTreeMap<String, String>,
17}
18
19impl MemoryFileResolver {
20  pub fn new() -> Self {
21    Self::default()
22  }
23
24  pub fn insert(&mut self, path: impl Into<String>, content: impl Into<String>) -> Option<String> {
25    self.files.insert(path.into(), content.into())
26  }
27
28  pub fn with_file(mut self, path: impl Into<String>, content: impl Into<String>) -> Self {
29    self.insert(path, content);
30    self
31  }
32}
33
34impl FileResolver for MemoryFileResolver {
35  fn resolve_file(&self, file: &RulepackReferencedFile) -> RenderResult<String> {
36    let key = logical_path_key(&file.path)?;
37    self.files.get(&key).cloned().ok_or_else(|| {
38      crate::rulepack_render::RulepackRenderError::new(format!(
39        "referenced rulepack file {key} is missing"
40      ))
41    })
42  }
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct BlobStore {
47  blobs: BTreeMap<String, String>,
48}
49
50impl BlobStore {
51  pub fn new() -> Self {
52    Self::default()
53  }
54
55  pub fn insert(&mut self, id: impl Into<String>, content: impl Into<String>) -> Option<String> {
56    self.blobs.insert(id.into(), content.into())
57  }
58
59  pub fn get(&self, id: &str) -> Option<&str> {
60    self.blobs.get(id).map(String::as_str)
61  }
62}
63
64#[derive(Debug, Clone, Default)]
65pub struct BlobFileResolver {
66  blobs: BlobStore,
67  path_to_blob: BTreeMap<String, String>,
68}
69
70impl BlobFileResolver {
71  pub fn new(blobs: BlobStore) -> Self {
72    Self {
73      blobs,
74      path_to_blob: BTreeMap::new(),
75    }
76  }
77
78  pub fn insert_mapping(
79    &mut self,
80    path: impl Into<String>,
81    blob_id: impl Into<String>,
82  ) -> Option<String> {
83    self.path_to_blob.insert(path.into(), blob_id.into())
84  }
85
86  pub fn with_mapping(mut self, path: impl Into<String>, blob_id: impl Into<String>) -> Self {
87    self.insert_mapping(path, blob_id);
88    self
89  }
90}
91
92impl FileResolver for BlobFileResolver {
93  fn resolve_file(&self, file: &RulepackReferencedFile) -> RenderResult<String> {
94    let key = logical_path_key(&file.path)?;
95    let blob_id = self.path_to_blob.get(&key).ok_or_else(|| {
96      crate::rulepack_render::RulepackRenderError::new(format!(
97        "referenced rulepack file {key} has no blob mapping"
98      ))
99    })?;
100    self.blobs.get(blob_id).map(str::to_string).ok_or_else(|| {
101      crate::rulepack_render::RulepackRenderError::new(format!(
102        "referenced rulepack file {key} maps to missing blob {blob_id}"
103      ))
104    })
105  }
106}
107
108pub(crate) fn referenced_rulepack_files(
109  document: &RulepackDocument,
110) -> RenderResult<Vec<RulepackReferencedFile>> {
111  let mut files = Vec::new();
112  for rule in &document.rules {
113    if let Some(path) = &rule.path {
114      validate_relative_rulepack_path(
115        &format!("rulepack {} rule {}", document.rulepack.name, rule.name),
116        path,
117        ".oxirule.toml",
118      )?;
119      files.push(RulepackReferencedFile {
120        kind: RulepackReferencedFileKind::Rule,
121        path: path.clone(),
122      });
123    }
124  }
125  for group_file in &document.group_files {
126    if let Some(path) = &group_file.path {
127      validate_relative_rulepack_path(
128        &format!("rulepack {} group file", document.rulepack.name),
129        path,
130        ".oxirule-group.toml",
131      )?;
132      files.push(RulepackReferencedFile {
133        kind: RulepackReferencedFileKind::Group,
134        path: path.clone(),
135      });
136    }
137  }
138  Ok(files)
139}
140
141pub(crate) fn validate_rule_content_or_path(label: &str, rule: &RulepackRule) -> RenderResult<()> {
142  validate_content_or_path(
143    label,
144    rule.content.as_deref(),
145    rule.path.as_deref(),
146    ".oxirule.toml",
147  )
148}
149
150pub(crate) fn validate_group_content_or_path(
151  label: &str,
152  group_file: &RulepackGroupFile,
153) -> RenderResult<()> {
154  validate_content_or_path(
155    label,
156    group_file.content.as_deref(),
157    group_file.path.as_deref(),
158    ".oxirule-group.toml",
159  )
160}
161
162pub(crate) fn embedded_or_resolved_file<R: FileResolver + ?Sized>(
163  file: RulepackReferencedFile,
164  embedded: Option<&str>,
165  resolver: &R,
166  variables: &BTreeMap<String, String>,
167) -> RenderResult<RenderedRulepackFile> {
168  let raw = match embedded {
169    Some(content) => content.to_string(),
170    None => resolver.resolve_file(&file)?,
171  };
172  Ok(RenderedRulepackFile {
173    kind: file.kind,
174    path: file.path,
175    content: super::render_text(&raw, variables),
176  })
177}
178
179fn validate_content_or_path(
180  label: &str,
181  content: Option<&str>,
182  path: Option<&Path>,
183  suffix: &str,
184) -> RenderResult<()> {
185  match (content, path) {
186    (Some(_), Some(_)) => fail(format!("{label} must use either content or path, not both")),
187    (None, None) => fail(format!("{label} must include content or path")),
188    (Some(content), None) => {
189      if content.trim().is_empty() {
190        return fail(format!("{label} content must not be empty"));
191      }
192      Ok(())
193    }
194    (None, Some(path)) => validate_relative_rulepack_path(label, path, suffix),
195  }
196}
197
198fn validate_relative_rulepack_path(label: &str, path: &Path, suffix: &str) -> RenderResult<()> {
199  let value = logical_path_key(path)?;
200  if !value.ends_with(suffix) {
201    return fail(format!("{label} path must end with {suffix}"));
202  }
203  Ok(())
204}
205
206fn logical_path_key(path: &Path) -> RenderResult<String> {
207  let Some(value) = path.to_str() else {
208    return fail(format!(
209      "rulepack path is not valid UTF-8: {}",
210      path.display()
211    ));
212  };
213  if value.trim().is_empty()
214    || value.contains('\\')
215    || value.bytes().any(|byte| byte.is_ascii_control())
216  {
217    return fail(format!(
218      "rulepack path is not a safe relative path: {value}"
219    ));
220  }
221  if path.is_absolute() {
222    return fail(format!("rulepack path must be relative: {value}"));
223  }
224  let mut components = 0usize;
225  for component in path.components() {
226    match component {
227      Component::Normal(part) if !part.is_empty() => components += 1,
228      Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
229        return fail(format!(
230          "rulepack path is not a safe relative path: {value}"
231        ));
232      }
233      Component::Normal(_) => {
234        return fail(format!(
235          "rulepack path is not a safe relative path: {value}"
236        ));
237      }
238    }
239  }
240  if components == 0 || value.split('/').any(str::is_empty) {
241    return fail(format!(
242      "rulepack path is not a safe relative path: {value}"
243    ));
244  }
245  Ok(value.to_string())
246}
247
248#[allow(dead_code)]
249fn _pathbuf_from_key(value: &str) -> PathBuf {
250  PathBuf::from(value)
251}