Skip to main content

helm_schema/
flatten.rs

1//! Prepare `$ref`s in a generated schema before it's written to disk.
2//!
3//! Fully inlined export mode is delegated to the [`jsonschema`] crate's
4//! [`dereference`](jsonschema::dereference) helper, which sits on top of
5//! [`referencing`](::jsonschema::Retrieve) — the same ref-resolution
6//! library that the broader JSON Schema validator ecosystem uses. This
7//! gives us battle-tested behaviour for every `$ref` shape we care about:
8//!
9//! - file refs with or without `#/json/pointer` fragments
10//! - URL refs with or without `#/json/pointer` fragments
11//! - bare fragment refs (`#/$defs/foo`) against the current document
12//! - RFC 6901 escapes (`~0`, `~1`) inside pointers
13//! - relative-URI resolution against a base
14//! - JSON Schema drafts 4 through 2020-12 (we ship draft-07)
15//! - cycle detection (left in place as `$ref` strings rather than
16//!   recursing forever)
17//!
18//! All we own is the [`jsonschema::Retrieve`] implementation that maps URIs back to
19//! their content — files from the chart-local filesystem and URLs over
20//! HTTP via `ureq`, both gated by an explicit fetch policy.
21//!
22//! Self-contained output mode keeps use sites as `$ref`s but re-homes
23//! external documents under root-level `$defs`. For tests, the lower-level
24//! entry points accept any `Retrieve` impl so callers can wire in an
25//! in-memory map keyed by URI and avoid disk I/O entirely.
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29
30use helm_schema_json_schema_walk::try_visit_subschemas_mut;
31use jsonschema::{Retrieve, Uri};
32use referencing::uri;
33use serde_json::{Map, Value};
34use tracing::instrument;
35use url::Url;
36
37use crate::error::{CliError, EngineResult};
38use crate::fetch_policy::FetchPolicy;
39use crate::load_budget::{LoadBudget, read_to_end_capped};
40
41/// Inline every `$ref` in `schema` against the filesystem rooted at
42/// `base_dir`.
43///
44/// Relative refs in the schema (and in any document loaded transitively)
45/// resolve against the directory each ref originates from — the standard
46/// JSON Schema base-URI rule. The base URI is constructed from `base_dir` as
47/// an absolute `file:` URL.
48///
49/// # Errors
50///
51/// Returns [`CliError::Referencing`] for any ref-resolution failure
52/// (file not found, JSON parse error, cycle the underlying resolver
53/// can't break, network/file refs denied by fetch policy, …), or an error when
54/// `base_dir` cannot be represented as an absolute file URI. The underlying
55/// error is wrapped with enough detail for an operator to find the bad ref.
56#[instrument(skip_all)]
57pub fn flatten_refs(
58    schema: &Value,
59    base_dir: &Path,
60    fetch_policy: FetchPolicy,
61    load_budget: LoadBudget,
62) -> EngineResult<Value> {
63    let base_uri = directory_file_uri(base_dir)?;
64    let retriever = FsHttpRetrieve::new(fetch_policy, load_budget);
65    flatten_with_retriever(schema, &base_uri, retriever)
66}
67
68/// Fully inline already-prepared refs without allowing file/URL retrieval.
69///
70/// This is the final-output counterpart to [`flatten_refs`]. Input assembly is
71/// responsible for fetching and preparing external refs. If an external ref
72/// reaches this pass, the schema is not self-contained and the run fails.
73///
74/// # Errors
75///
76/// Returns [`CliError::Referencing`] when a reference is malformed,
77/// unresolved, or still points to an external document, or an error when
78/// `base_dir` cannot be represented as an absolute file URI.
79#[instrument(skip_all)]
80pub fn flatten_prepared_refs(schema: &Value, base_dir: &Path) -> EngineResult<Value> {
81    let base_uri = directory_file_uri(base_dir)?;
82    flatten_with_retriever(schema, &base_uri, NoExternalRetrieve)
83}
84
85/// Resolve external `$ref`s into root-level `$defs` entries while preserving
86/// internal refs as refs.
87///
88/// The result is self-contained: file and URL references are loaded through
89/// the same [`Retrieve`] implementation used by [`flatten_refs`], but the
90/// referenced schema is re-homed under `#/$defs/...` instead of being inlined
91/// at each use site.
92///
93/// # Errors
94///
95/// Returns an error when `base_dir` cannot be represented as an absolute file
96/// URI, a reference URI is malformed, a referenced document cannot be
97/// retrieved or parsed, or a load budget is exceeded.
98#[instrument(skip_all)]
99pub fn bundle_refs(
100    schema: Value,
101    base_dir: &Path,
102    fetch_policy: FetchPolicy,
103    load_budget: LoadBudget,
104) -> EngineResult<Value> {
105    let mut namespace = BundleNamespace::default();
106    bundle_refs_in_namespace(schema, base_dir, fetch_policy, load_budget, &mut namespace)
107}
108
109/// Bundle one document using names reserved across a larger output assembly.
110pub(crate) fn bundle_refs_in_namespace(
111    schema: Value,
112    base_dir: &Path,
113    fetch_policy: FetchPolicy,
114    load_budget: LoadBudget,
115    namespace: &mut BundleNamespace,
116) -> EngineResult<Value> {
117    let base_uri = directory_file_uri(base_dir)?;
118    let retriever = FsHttpRetrieve::new(fetch_policy, load_budget);
119    bundle_with_retriever_in_namespace(schema, &base_uri, retriever, namespace)
120}
121
122/// Validate and normalize already-prepared refs without allowing file/URL
123/// retrieval.
124///
125/// Internal refs are preserved. External refs fail, because input assembly
126/// should have already re-homed them under root-level `$defs`.
127///
128/// # Errors
129///
130/// Returns an error when `base_dir` cannot be represented as an absolute file
131/// URI, or a reference is malformed, unresolved, or still points to an
132/// external document.
133#[instrument(skip_all)]
134pub fn bundle_prepared_refs(schema: Value, base_dir: &Path) -> EngineResult<Value> {
135    let base_uri = directory_file_uri(base_dir)?;
136    bundle_with_retriever(schema, &base_uri, NoExternalRetrieve)
137}
138
139/// Low-level dereference entry point: lets callers (most importantly,
140/// tests) plug in a custom [`Retrieve`] so they don't have to touch the
141/// filesystem to exercise the ref-resolution behaviour.
142///
143/// `base_uri` is the URI relative refs resolve against. Use a synthetic
144/// `file:///<something>/` for in-memory tests.
145///
146/// # Errors
147///
148/// Returns [`CliError::Referencing`] on any ref-resolution failure.
149#[instrument(skip_all)]
150pub fn flatten_with_retriever(
151    schema: &Value,
152    base_uri: &str,
153    retriever: impl Retrieve + 'static,
154) -> EngineResult<Value> {
155    let dereferenced = jsonschema::options()
156        .with_base_uri(base_uri.to_string())
157        .with_retriever(retriever)
158        .dereference(schema)?;
159    Ok(dereferenced)
160}
161
162/// Low-level bundling entry point for tests and custom retrievers.
163///
164/// `base_uri` is the URI relative refs resolve against. External refs are
165/// fetched through `retriever`, rewritten to root-level `$defs`, and any refs
166/// inside fetched schemas are interpreted relative to the document they came
167/// from before being re-homed.
168///
169/// # Errors
170///
171/// Returns an error when the base URI or a reference is malformed, retrieval
172/// fails, or the resulting definitions cannot be inserted safely.
173#[instrument(skip_all)]
174pub fn bundle_with_retriever(
175    schema: Value,
176    base_uri: &str,
177    retriever: impl Retrieve,
178) -> EngineResult<Value> {
179    let mut namespace = BundleNamespace::default();
180    bundle_with_retriever_in_namespace(schema, base_uri, retriever, &mut namespace)
181}
182
183fn bundle_with_retriever_in_namespace(
184    mut schema: Value,
185    base_uri: &str,
186    retriever: impl Retrieve,
187    namespace: &mut BundleNamespace,
188) -> EngineResult<Value> {
189    namespace.reserve_schema(&schema);
190    let root_document_uri = document_uri(&uri::from_str(base_uri)?)?;
191    let root_base_uri = effective_base_uri(&schema, &root_document_uri)?;
192    let root_document_uris = BTreeSet::from([
193        root_document_uri.as_str().to_string(),
194        root_base_uri.as_str().to_string(),
195    ]);
196    let mut state = BundleState::new(retriever, root_document_uris, namespace);
197    state.bundle_schema(&mut schema, &root_document_uri)?;
198    state.insert_definitions(&mut schema)?;
199    Ok(schema)
200}
201
202/// Generated-definition name allocator shared by separately bundled documents.
203#[derive(Default)]
204pub(crate) struct BundleNamespace {
205    definition_names: BTreeSet<String>,
206    names_by_target_uri: BTreeMap<String, String>,
207    next_definition_id: usize,
208}
209
210impl BundleNamespace {
211    pub(crate) fn reserve_schema(&mut self, schema: &Value) {
212        // Bundled targets are emitted only under `$defs`; legacy
213        // `definitions` entries therefore occupy a distinct reference
214        // namespace and cannot collide with generated target names.
215        self.definition_names
216            .extend(existing_definition_names(schema));
217    }
218
219    fn next_definition_name(&mut self) -> String {
220        loop {
221            self.next_definition_id += 1;
222            let name = format!("schema{}", self.next_definition_id);
223            if self.definition_names.insert(name.clone()) {
224                return name;
225            }
226        }
227    }
228}
229
230struct BundleState<'a, R> {
231    retriever: R,
232    root_document_uris: BTreeSet<String>,
233    definitions: BTreeMap<String, Value>,
234    namespace: &'a mut BundleNamespace,
235}
236
237impl<'a, R: Retrieve> BundleState<'a, R> {
238    fn new(
239        retriever: R,
240        root_document_uris: BTreeSet<String>,
241        namespace: &'a mut BundleNamespace,
242    ) -> Self {
243        Self {
244            retriever,
245            root_document_uris,
246            definitions: BTreeMap::new(),
247            namespace,
248        }
249    }
250
251    fn bundle_schema(
252        &mut self,
253        schema: &mut Value,
254        current_document_uri: &Uri<String>,
255    ) -> EngineResult<()> {
256        let current_document_uri = effective_base_uri(schema, current_document_uri)?;
257        if let Some(reference) = schema_reference(schema) {
258            let target_uri = uri::resolve_against(&current_document_uri.borrow(), &reference)?;
259            if self.should_preserve_reference(&target_uri, &current_document_uri)? {
260                return Ok(());
261            }
262            let definition_name = self.definition_name_for_target(&target_uri)?;
263            *schema = definition_ref(&definition_name);
264            return Ok(());
265        }
266
267        try_visit_subschemas_mut(schema, &mut |subschema| {
268            self.bundle_schema(subschema, &current_document_uri)
269        })
270    }
271
272    fn should_preserve_reference(
273        &self,
274        target_uri: &Uri<String>,
275        current_document_uri: &Uri<String>,
276    ) -> EngineResult<bool> {
277        let target_document_uri = document_uri(target_uri)?;
278        Ok(self.is_root_document(&target_document_uri)
279            && self.is_root_document(current_document_uri))
280    }
281
282    fn definition_name_for_target(&mut self, target_uri: &Uri<String>) -> EngineResult<String> {
283        let target_key = target_uri.as_str().to_string();
284        if let Some(name) = self.namespace.names_by_target_uri.get(&target_key) {
285            return Ok(name.clone());
286        }
287
288        let name = self.namespace.next_definition_name();
289        self.namespace
290            .names_by_target_uri
291            .insert(target_key, name.clone());
292
293        let target_document_uri = document_uri(target_uri)?;
294        let mut target_schema = self.resolve_target_schema(target_uri, &target_document_uri)?;
295        self.bundle_schema(&mut target_schema, &target_document_uri)?;
296        self.definitions.insert(name.clone(), target_schema);
297
298        Ok(name)
299    }
300
301    fn resolve_target_schema(
302        &self,
303        target_uri: &Uri<String>,
304        target_document_uri: &Uri<String>,
305    ) -> EngineResult<Value> {
306        if self.is_root_document(target_document_uri) {
307            return Err(CliError::RefBundling(format!(
308                "cannot bundle non-local ref back to root document: {target_uri}"
309            )));
310        }
311
312        let document = self
313            .retriever
314            .retrieve(target_document_uri)
315            .map_err(|err| {
316                CliError::RefBundling(format!("retrieve {target_document_uri}: {err}"))
317            })?;
318        select_fragment(document, target_uri)
319    }
320
321    fn insert_definitions(self, schema: &mut Value) -> EngineResult<()> {
322        if self.definitions.is_empty() {
323            return Ok(());
324        }
325
326        let Value::Object(root) = schema else {
327            return Err(CliError::RefBundling(
328                "cannot insert bundled definitions into non-object root schema".to_string(),
329            ));
330        };
331        let entry = root
332            .entry("$defs".to_string())
333            .or_insert_with(|| Value::Object(Map::new()));
334        let Value::Object(existing) = entry else {
335            return Err(CliError::RefBundling(
336                "cannot insert bundled definitions because root $defs is not an object".to_string(),
337            ));
338        };
339        for (name, definition) in self.definitions {
340            existing.insert(name, definition);
341        }
342        Ok(())
343    }
344
345    fn is_root_document(&self, document_uri: &Uri<String>) -> bool {
346        self.root_document_uris.contains(document_uri.as_str())
347    }
348}
349
350/// Production [`Retrieve`]: file URIs go through `std::fs`; HTTP/HTTPS
351/// URIs go through a single shared `ureq` agent, both gated by an explicit
352/// [`FetchPolicy`].
353struct FsHttpRetrieve {
354    fetch_policy: FetchPolicy,
355    load_budget: LoadBudget,
356    agent: ureq::Agent,
357}
358
359impl FsHttpRetrieve {
360    fn new(fetch_policy: FetchPolicy, load_budget: LoadBudget) -> Self {
361        Self {
362            fetch_policy,
363            load_budget,
364            agent: ureq::Agent::new_with_defaults(),
365        }
366    }
367}
368
369impl Retrieve for FsHttpRetrieve {
370    fn retrieve(
371        &self,
372        uri: &Uri<String>,
373    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
374        let scheme = uri.scheme().as_str().to_ascii_lowercase();
375        match scheme.as_str() {
376            "file" => {
377                let host = uri
378                    .authority()
379                    .map(|authority| authority.host())
380                    .unwrap_or("");
381                self.fetch_policy
382                    .validate_file_host(host)
383                    .map_err(|err| format!("$ref to {uri} but {err}"))?;
384                let path = file_uri_path(uri)?;
385                let mut file = std::fs::File::open(&path)
386                    .map_err(|error| format!("open {}: {error}", path.display()))?;
387                let bytes = read_to_end_capped(
388                    &mut file,
389                    self.load_budget.max_schema_document_bytes,
390                    path.display().to_string(),
391                )
392                .map_err(|e| e.to_string())?;
393                let value: Value = serde_json::from_slice(&bytes)
394                    .map_err(|error| format!("parse {}: {error}", path.display()))?;
395                Ok(value)
396            }
397            "http" | "https" => {
398                let host = uri.authority().map(|authority| authority.host());
399                self.fetch_policy
400                    .validate_network_host(host)
401                    .map_err(|err| format!("$ref to {uri} but {err}"))?;
402                let resp = self
403                    .agent
404                    .get(uri.as_str())
405                    .call()
406                    .map_err(|e| format!("fetch {uri}: {e}"))?;
407                let mut body = resp.into_body();
408                let mut reader = body.as_reader();
409                let bytes = read_to_end_capped(
410                    &mut reader,
411                    self.load_budget.max_schema_document_bytes,
412                    uri.as_str().to_string(),
413                )
414                .map_err(|e| e.to_string())?;
415                let value: Value =
416                    serde_json::from_slice(&bytes).map_err(|e| format!("parse {uri}: {e}"))?;
417                Ok(value)
418            }
419            other => Err(format!("unsupported $ref scheme: {other} (uri={uri})").into()),
420        }
421    }
422}
423
424struct NoExternalRetrieve;
425
426impl Retrieve for NoExternalRetrieve {
427    fn retrieve(
428        &self,
429        uri: &Uri<String>,
430    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
431        Err(format!("external $ref remained after input preparation: {uri}").into())
432    }
433}
434
435fn schema_reference(schema: &Value) -> Option<String> {
436    let Value::Object(object) = schema else {
437        return None;
438    };
439    object
440        .get("$ref")
441        .and_then(Value::as_str)
442        .map(str::to_string)
443}
444
445fn document_uri(uri: &Uri<String>) -> EngineResult<Uri<String>> {
446    let document = uri.strip_fragment().as_str().to_string();
447    Uri::parse(document)
448        .map_err(|err| CliError::RefBundling(format!("parse document uri for {uri}: {err:?}")))
449}
450
451fn effective_base_uri(
452    schema: &Value,
453    current_document_uri: &Uri<String>,
454) -> EngineResult<Uri<String>> {
455    let Some(id) = schema
456        .as_object()
457        .and_then(|object| object.get("$id"))
458        .and_then(Value::as_str)
459    else {
460        return Ok(current_document_uri.clone());
461    };
462
463    let resolved = uri::resolve_against(&current_document_uri.borrow(), id)?;
464    document_uri(&resolved)
465}
466
467fn select_fragment(document: Value, target_uri: &Uri<String>) -> EngineResult<Value> {
468    let Some(fragment) = target_uri.fragment() else {
469        return Ok(document);
470    };
471    let pointer = fragment.decode().to_string().map_err(|_| {
472        CliError::RefBundling(format!("decode json pointer fragment for {target_uri}"))
473    })?;
474    if pointer.is_empty() {
475        return Ok(document);
476    }
477    if !pointer.starts_with('/') {
478        return Err(CliError::RefBundling(format!(
479            "unsupported non-json-pointer fragment in {target_uri}"
480        )));
481    }
482
483    document.pointer(&pointer).cloned().ok_or_else(|| {
484        CliError::RefBundling(format!("json pointer {pointer} not found in {target_uri}"))
485    })
486}
487
488fn existing_definition_names(schema: &Value) -> BTreeSet<String> {
489    schema
490        .get("$defs")
491        .and_then(Value::as_object)
492        .map(|definitions| definitions.keys().cloned().collect())
493        .unwrap_or_default()
494}
495
496fn definition_ref(name: &str) -> Value {
497    Value::Object(Map::from_iter([(
498        "$ref".to_string(),
499        Value::String(format!("#/$defs/{name}")),
500    )]))
501}
502
503fn directory_file_uri(path: &Path) -> EngineResult<String> {
504    let path = if path.as_os_str().is_empty() {
505        Path::new(".")
506    } else {
507        path
508    };
509    let absolute = path.canonicalize().or_else(|_| std::path::absolute(path))?;
510    let uri = Url::from_directory_path(&absolute)
511        .map_err(|()| CliError::InvalidFileUriPath { path: absolute })?;
512    Ok(uri.into())
513}
514
515fn file_uri_path(uri: &Uri<String>) -> EngineResult<PathBuf> {
516    let invalid_uri = || CliError::InvalidFileUri {
517        uri: uri.as_str().to_string(),
518    };
519    Url::parse(uri.as_str())
520        .map_err(|_| invalid_uri())?
521        .to_file_path()
522        .map_err(|()| invalid_uri())
523}
524
525#[cfg(test)]
526#[path = "tests/flatten.rs"]
527mod tests;