online_dsl_forge/rulepack_render/
files.rs1use 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}