citum_schema_style/style/
resolution.rs1use std::collections::HashSet;
9
10use crate::{ResolutionError, StyleInfo, StyleResolver, options, registry, style_base};
11
12use super::Style;
13use super::overlay::merge_style_overlay;
14
15impl Style {
16 #[must_use]
30 #[allow(
31 clippy::panic,
32 reason = "Convenience API for infallible resolution contexts"
33 )]
34 pub fn into_resolved(self) -> Self {
35 self.try_into_resolved()
36 .unwrap_or_else(|err| panic!("style resolution failed: {err}"))
37 }
38
39 pub fn try_into_resolved(self) -> Result<Self, ResolutionError> {
50 self.try_into_resolved_with(None)
51 }
52
53 pub fn try_into_resolved_with(
63 self,
64 resolver: Option<&StyleResolver>,
65 ) -> Result<Self, ResolutionError> {
66 self.try_into_resolved_recursive_with_depth(resolver, &mut HashSet::new(), 0)
67 }
68
69 #[must_use]
76 #[allow(
77 clippy::panic,
78 reason = "Convenience API for infallible resolution contexts"
79 )]
80 pub fn into_resolved_recursive(self, visited: &mut HashSet<String>) -> Self {
81 self.try_into_resolved_recursive(visited)
82 .unwrap_or_else(|err| panic!("style resolution failed: {err}"))
83 }
84
85 pub fn try_into_resolved_recursive(
93 self,
94 visited: &mut HashSet<String>,
95 ) -> Result<Self, ResolutionError> {
96 self.try_into_resolved_recursive_with(None, visited)
97 }
98
99 pub fn try_into_resolved_recursive_with(
106 self,
107 resolver: Option<&StyleResolver>,
108 visited: &mut HashSet<String>,
109 ) -> Result<Self, ResolutionError> {
110 self.try_into_resolved_recursive_with_depth(resolver, visited, 0)
111 }
112
113 fn try_into_resolved_recursive_with_depth(
115 self,
116 resolver: Option<&StyleResolver>,
117 visited: &mut HashSet<String>,
118 depth: usize,
119 ) -> Result<Self, ResolutionError> {
120 const MAX_DEPTH: usize = 5;
121
122 let root_label = self
125 .info
126 .id
127 .as_deref()
128 .or(self.info.title.as_deref())
129 .unwrap_or("<root>");
130 check_citum_version(root_label, &self.info)?;
131
132 let Some(base_ref) = self.extends.clone() else {
133 let mut style = self;
134 crate::template::resolve_style_template_variants(&mut style, None)?;
135 options::scoped::apply_scoped_style_options(&mut style);
136 return Ok(style);
137 };
138
139 if depth >= MAX_DEPTH {
140 let uri = base_ref.key();
141 return Err(ResolutionError::UriResolutionFailed {
142 uri: uri.to_string(),
143 reason: format!("inheritance chain exceeds maximum depth of {MAX_DEPTH}"),
144 });
145 }
146
147 let key = base_ref.key().to_string();
148 if visited.contains(&key) {
149 return Err(ResolutionError::InheritanceLoop { base: key });
150 }
151 visited.insert(key);
152
153 let is_profile = self.resolves_as_profile();
154 let pin = self.extends_pin.clone();
155 let mut effective = match base_ref {
156 style_base::StyleReference::Base(base) => {
157 if pin.is_some() {
158 return Err(ResolutionError::UriResolutionFailed {
159 uri: base.key().to_string(),
160 reason:
161 "extends-pin is only supported for URI-based parents (https://, cid:); \
162 builtin StyleBase parents are content-fixed already"
163 .to_string(),
164 });
165 }
166 base.try_resolve_with_visited(resolver, visited)?
167 }
168 style_base::StyleReference::Uri(ref uri) => {
169 let base_style = resolve_style_reference_uri(uri, resolver)?;
170 if let Some(ref expected) = pin {
171 verify_parent_pin(uri, &base_style, expected)?;
172 }
173 base_style.try_into_resolved_recursive_with_depth(resolver, visited, depth + 1)?
174 }
175 };
176 if is_profile {
177 self.validate_profile_shape()?;
178 }
179
180 let inherited_variants = crate::template::inherited_variant_context(&effective);
181 merge_style_overlay(&mut effective, &self);
182 effective.version = self.version;
183 effective.extends = self.extends;
184 effective.extends_pin = self.extends_pin;
185 effective.raw_yaml = self.raw_yaml;
186 crate::template::resolve_style_template_variants(
187 &mut effective,
188 inherited_variants.as_ref(),
189 )?;
190 options::scoped::apply_scoped_style_options(&mut effective);
191 if is_profile {
192 effective.extends = None;
193 }
194
195 Ok(effective)
196 }
197 fn style_kind(&self) -> Option<registry::StyleKind> {
198 let id = self.info.id.as_deref()?;
199 registry::StyleRegistry::load_default()
200 .resolve(id)
201 .and_then(|entry| entry.kind.clone())
202 }
203
204 fn resolves_as_profile(&self) -> bool {
205 self.style_kind() == Some(registry::StyleKind::Profile)
206 }
207}
208
209#[allow(
210 clippy::panic,
211 reason = "Multihash::wrap on a 32-byte SHA-256 digest is infallible by construction"
212)]
213fn schema_compute_style_cid(bytes: &[u8]) -> String {
214 use cid::Cid;
215 use multihash::Multihash;
216 use sha2::{Digest, Sha256};
217
218 const RAW_CODEC: u64 = 0x55;
219 const SHA256_CODE: u64 = 0x12;
220
221 let digest: [u8; 32] = Sha256::digest(bytes).into();
222 let mh = Multihash::<64>::wrap(SHA256_CODE, &digest)
223 .unwrap_or_else(|_| panic!("32-byte SHA-256 digest fits in Multihash<64>"));
224 Cid::new_v1(RAW_CODEC, mh).to_string()
225}
226
227fn schema_canonicalize_cid(s: &str) -> Result<String, ResolutionError> {
229 use cid::Cid;
230 let trimmed = s.strip_prefix("cid:").unwrap_or(s);
231 let cid: Cid =
232 trimmed
233 .parse()
234 .map_err(|err: cid::Error| ResolutionError::UriResolutionFailed {
235 uri: s.to_string(),
236 reason: format!("invalid CID '{s}': {err}"),
237 })?;
238 Ok(cid.to_string())
239}
240
241fn verify_parent_pin(uri: &str, parent: &Style, expected_pin: &str) -> Result<(), ResolutionError> {
249 let expected = schema_canonicalize_cid(expected_pin)?;
250 let bytes =
251 serde_yaml::to_string(parent).map_err(|err| ResolutionError::UriResolutionFailed {
252 uri: uri.to_string(),
253 reason: format!("re-serialize for extends-pin verification: {err}"),
254 })?;
255 let actual = schema_compute_style_cid(bytes.as_bytes());
256 if actual == expected {
257 Ok(())
258 } else {
259 Err(ResolutionError::IntegrityFailure {
260 uri: uri.to_string(),
261 expected,
262 actual,
263 })
264 }
265}
266
267pub fn check_citum_version(uri: &str, info: &StyleInfo) -> Result<(), ResolutionError> {
283 let Some(req_str) = info.citum_version.as_ref() else {
284 return Ok(());
285 };
286 let req =
287 semver::VersionReq::parse(req_str).map_err(|err| ResolutionError::UriResolutionFailed {
288 uri: uri.to_string(),
289 reason: format!("invalid `info.citum-version` requirement '{req_str}': {err}"),
290 })?;
291 let engine_str = env!("CARGO_PKG_VERSION");
292 let engine =
293 semver::Version::parse(engine_str).map_err(|err| ResolutionError::UriResolutionFailed {
294 uri: uri.to_string(),
295 reason: format!("unparseable engine version `{engine_str}`: {err}"),
296 })?;
297 if req.matches(&engine) {
298 Ok(())
299 } else {
300 Err(ResolutionError::VersionMismatch {
301 uri: uri.to_string(),
302 required: req_str.clone(),
303 declared: engine_str.to_string(),
304 })
305 }
306}
307
308fn resolve_style_reference_uri(
309 uri: &str,
310 resolver: Option<&StyleResolver>,
311) -> Result<Style, ResolutionError> {
312 if let Some(resolver) = resolver {
313 let style = resolver
314 .resolve_style(uri)
315 .map_err(|e| ResolutionError::from_resolver_error(uri, e))?;
316 check_citum_version(uri, &style.info)?;
317 return Ok(style);
318 }
319
320 let Some(raw_path) = uri.strip_prefix("file://") else {
321 return Err(ResolutionError::UriResolutionFailed {
322 uri: uri.to_string(),
323 reason: "unsupported scheme; an external style resolver is required".to_string(),
324 });
325 };
326 let path = std::path::Path::new(raw_path);
327 let bytes = std::fs::read(path).map_err(|e| ResolutionError::UriResolutionFailed {
328 uri: uri.to_string(),
329 reason: e.to_string(),
330 })?;
331 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
332 let style: Style = match ext {
333 "cbor" => ciborium::de::from_reader(std::io::Cursor::new(&bytes)).map_err(|e| {
334 ResolutionError::UriResolutionFailed {
335 uri: uri.to_string(),
336 reason: e.to_string(),
337 }
338 })?,
339 "json" => {
340 serde_json::from_slice(&bytes).map_err(|e| ResolutionError::UriResolutionFailed {
341 uri: uri.to_string(),
342 reason: e.to_string(),
343 })?
344 }
345 _ => Style::from_yaml_bytes(&bytes).map_err(|e| ResolutionError::UriResolutionFailed {
346 uri: uri.to_string(),
347 reason: e.to_string(),
348 })?,
349 };
350 check_citum_version(uri, &style.info)?;
351 Ok(style)
352}