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 base_uri = directory_file_uri(base_dir)?;
106    let retriever = FsHttpRetrieve::new(fetch_policy, load_budget);
107    bundle_with_retriever(schema, &base_uri, retriever)
108}
109
110/// Validate and normalize already-prepared refs without allowing file/URL
111/// retrieval.
112///
113/// Internal refs are preserved. External refs fail, because input assembly
114/// should have already re-homed them under root-level `$defs`.
115///
116/// # Errors
117///
118/// Returns an error when `base_dir` cannot be represented as an absolute file
119/// URI, or a reference is malformed, unresolved, or still points to an
120/// external document.
121#[instrument(skip_all)]
122pub fn bundle_prepared_refs(schema: Value, base_dir: &Path) -> EngineResult<Value> {
123    let base_uri = directory_file_uri(base_dir)?;
124    bundle_with_retriever(schema, &base_uri, NoExternalRetrieve)
125}
126
127/// Low-level dereference entry point: lets callers (most importantly,
128/// tests) plug in a custom [`Retrieve`] so they don't have to touch the
129/// filesystem to exercise the ref-resolution behaviour.
130///
131/// `base_uri` is the URI relative refs resolve against. Use a synthetic
132/// `file:///<something>/` for in-memory tests.
133///
134/// # Errors
135///
136/// Returns [`CliError::Referencing`] on any ref-resolution failure.
137#[instrument(skip_all)]
138pub fn flatten_with_retriever(
139    schema: &Value,
140    base_uri: &str,
141    retriever: impl Retrieve + 'static,
142) -> EngineResult<Value> {
143    let dereferenced = jsonschema::options()
144        .with_base_uri(base_uri.to_string())
145        .with_retriever(retriever)
146        .dereference(schema)?;
147    Ok(dereferenced)
148}
149
150/// Low-level bundling entry point for tests and custom retrievers.
151///
152/// `base_uri` is the URI relative refs resolve against. External refs are
153/// fetched through `retriever`, rewritten to root-level `$defs`, and any refs
154/// inside fetched schemas are interpreted relative to the document they came
155/// from before being re-homed.
156///
157/// # Errors
158///
159/// Returns an error when the base URI or a reference is malformed, retrieval
160/// fails, or the resulting definitions cannot be inserted safely.
161#[instrument(skip_all)]
162pub fn bundle_with_retriever(
163    mut schema: Value,
164    base_uri: &str,
165    retriever: impl Retrieve,
166) -> EngineResult<Value> {
167    let root_document_uri = document_uri(&uri::from_str(base_uri)?)?;
168    let root_base_uri = effective_base_uri(&schema, &root_document_uri)?;
169    let root_document_uris = BTreeSet::from([
170        root_document_uri.as_str().to_string(),
171        root_base_uri.as_str().to_string(),
172    ]);
173    let existing_definition_names = existing_definition_names(&schema);
174    let mut state = BundleState::new(retriever, root_document_uris, existing_definition_names);
175    state.bundle_schema(&mut schema, &root_document_uri)?;
176    state.insert_definitions(&mut schema)?;
177    Ok(schema)
178}
179
180struct BundleState<R> {
181    retriever: R,
182    root_document_uris: BTreeSet<String>,
183    names_by_target_uri: BTreeMap<String, String>,
184    definitions: BTreeMap<String, Value>,
185    existing_definition_names: BTreeSet<String>,
186    next_definition_id: usize,
187}
188
189impl<R: Retrieve> BundleState<R> {
190    fn new(
191        retriever: R,
192        root_document_uris: BTreeSet<String>,
193        existing_definition_names: BTreeSet<String>,
194    ) -> Self {
195        Self {
196            retriever,
197            root_document_uris,
198            names_by_target_uri: BTreeMap::new(),
199            definitions: BTreeMap::new(),
200            existing_definition_names,
201            next_definition_id: 1,
202        }
203    }
204
205    fn bundle_schema(
206        &mut self,
207        schema: &mut Value,
208        current_document_uri: &Uri<String>,
209    ) -> EngineResult<()> {
210        let current_document_uri = effective_base_uri(schema, current_document_uri)?;
211        if let Some(reference) = schema_reference(schema) {
212            let target_uri = uri::resolve_against(&current_document_uri.borrow(), &reference)?;
213            if self.should_preserve_reference(&target_uri, &current_document_uri)? {
214                return Ok(());
215            }
216            let definition_name = self.definition_name_for_target(&target_uri)?;
217            *schema = definition_ref(&definition_name);
218            return Ok(());
219        }
220
221        try_visit_subschemas_mut(schema, &mut |subschema| {
222            self.bundle_schema(subschema, &current_document_uri)
223        })
224    }
225
226    fn should_preserve_reference(
227        &self,
228        target_uri: &Uri<String>,
229        current_document_uri: &Uri<String>,
230    ) -> EngineResult<bool> {
231        let target_document_uri = document_uri(target_uri)?;
232        Ok(self.is_root_document(&target_document_uri)
233            && self.is_root_document(current_document_uri))
234    }
235
236    fn definition_name_for_target(&mut self, target_uri: &Uri<String>) -> EngineResult<String> {
237        let target_key = target_uri.as_str().to_string();
238        if let Some(name) = self.names_by_target_uri.get(&target_key) {
239            return Ok(name.clone());
240        }
241
242        let name = self.next_definition_name();
243        self.names_by_target_uri.insert(target_key, name.clone());
244
245        let target_document_uri = document_uri(target_uri)?;
246        let mut target_schema = self.resolve_target_schema(target_uri, &target_document_uri)?;
247        self.bundle_schema(&mut target_schema, &target_document_uri)?;
248        self.definitions.insert(name.clone(), target_schema);
249
250        Ok(name)
251    }
252
253    fn resolve_target_schema(
254        &self,
255        target_uri: &Uri<String>,
256        target_document_uri: &Uri<String>,
257    ) -> EngineResult<Value> {
258        if self.is_root_document(target_document_uri) {
259            return Err(CliError::RefBundling(format!(
260                "cannot bundle non-local ref back to root document: {target_uri}"
261            )));
262        }
263
264        let document = self
265            .retriever
266            .retrieve(target_document_uri)
267            .map_err(|err| {
268                CliError::RefBundling(format!("retrieve {target_document_uri}: {err}"))
269            })?;
270        select_fragment(document, target_uri)
271    }
272
273    fn next_definition_name(&mut self) -> String {
274        loop {
275            let name = format!("schema{}", self.next_definition_id);
276            self.next_definition_id += 1;
277            if self.existing_definition_names.insert(name.clone()) {
278                return name;
279            }
280        }
281    }
282
283    fn insert_definitions(self, schema: &mut Value) -> EngineResult<()> {
284        if self.definitions.is_empty() {
285            return Ok(());
286        }
287
288        let Value::Object(root) = schema else {
289            return Err(CliError::RefBundling(
290                "cannot insert bundled definitions into non-object root schema".to_string(),
291            ));
292        };
293        let entry = root
294            .entry("$defs".to_string())
295            .or_insert_with(|| Value::Object(Map::new()));
296        let Value::Object(existing) = entry else {
297            return Err(CliError::RefBundling(
298                "cannot insert bundled definitions because root $defs is not an object".to_string(),
299            ));
300        };
301        for (name, definition) in self.definitions {
302            existing.insert(name, definition);
303        }
304        Ok(())
305    }
306
307    fn is_root_document(&self, document_uri: &Uri<String>) -> bool {
308        self.root_document_uris.contains(document_uri.as_str())
309    }
310}
311
312/// Production [`Retrieve`]: file URIs go through `std::fs`; HTTP/HTTPS
313/// URIs go through a single shared `ureq` agent, both gated by an explicit
314/// [`FetchPolicy`].
315struct FsHttpRetrieve {
316    fetch_policy: FetchPolicy,
317    load_budget: LoadBudget,
318    agent: ureq::Agent,
319}
320
321impl FsHttpRetrieve {
322    fn new(fetch_policy: FetchPolicy, load_budget: LoadBudget) -> Self {
323        Self {
324            fetch_policy,
325            load_budget,
326            agent: ureq::Agent::new_with_defaults(),
327        }
328    }
329}
330
331impl Retrieve for FsHttpRetrieve {
332    fn retrieve(
333        &self,
334        uri: &Uri<String>,
335    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
336        let scheme = uri.scheme().as_str().to_ascii_lowercase();
337        match scheme.as_str() {
338            "file" => {
339                let host = uri
340                    .authority()
341                    .map(|authority| authority.host())
342                    .unwrap_or("");
343                self.fetch_policy
344                    .validate_file_host(host)
345                    .map_err(|err| format!("$ref to {uri} but {err}"))?;
346                let path = file_uri_path(uri)?;
347                let mut file = std::fs::File::open(&path)
348                    .map_err(|error| format!("open {}: {error}", path.display()))?;
349                let bytes = read_to_end_capped(
350                    &mut file,
351                    self.load_budget.max_schema_document_bytes,
352                    path.display().to_string(),
353                )
354                .map_err(|e| e.to_string())?;
355                let value: Value = serde_json::from_slice(&bytes)
356                    .map_err(|error| format!("parse {}: {error}", path.display()))?;
357                Ok(value)
358            }
359            "http" | "https" => {
360                let host = uri.authority().map(|authority| authority.host());
361                self.fetch_policy
362                    .validate_network_host(host)
363                    .map_err(|err| format!("$ref to {uri} but {err}"))?;
364                let resp = self
365                    .agent
366                    .get(uri.as_str())
367                    .call()
368                    .map_err(|e| format!("fetch {uri}: {e}"))?;
369                let mut body = resp.into_body();
370                let mut reader = body.as_reader();
371                let bytes = read_to_end_capped(
372                    &mut reader,
373                    self.load_budget.max_schema_document_bytes,
374                    uri.as_str().to_string(),
375                )
376                .map_err(|e| e.to_string())?;
377                let value: Value =
378                    serde_json::from_slice(&bytes).map_err(|e| format!("parse {uri}: {e}"))?;
379                Ok(value)
380            }
381            other => Err(format!("unsupported $ref scheme: {other} (uri={uri})").into()),
382        }
383    }
384}
385
386struct NoExternalRetrieve;
387
388impl Retrieve for NoExternalRetrieve {
389    fn retrieve(
390        &self,
391        uri: &Uri<String>,
392    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
393        Err(format!("external $ref remained after input preparation: {uri}").into())
394    }
395}
396
397fn schema_reference(schema: &Value) -> Option<String> {
398    let Value::Object(object) = schema else {
399        return None;
400    };
401    object
402        .get("$ref")
403        .and_then(Value::as_str)
404        .map(str::to_string)
405}
406
407fn document_uri(uri: &Uri<String>) -> EngineResult<Uri<String>> {
408    let document = uri.strip_fragment().as_str().to_string();
409    Uri::parse(document)
410        .map_err(|err| CliError::RefBundling(format!("parse document uri for {uri}: {err:?}")))
411}
412
413fn effective_base_uri(
414    schema: &Value,
415    current_document_uri: &Uri<String>,
416) -> EngineResult<Uri<String>> {
417    let Some(id) = schema
418        .as_object()
419        .and_then(|object| object.get("$id"))
420        .and_then(Value::as_str)
421    else {
422        return Ok(current_document_uri.clone());
423    };
424
425    let resolved = uri::resolve_against(&current_document_uri.borrow(), id)?;
426    document_uri(&resolved)
427}
428
429fn select_fragment(document: Value, target_uri: &Uri<String>) -> EngineResult<Value> {
430    let Some(fragment) = target_uri.fragment() else {
431        return Ok(document);
432    };
433    let pointer = fragment.decode().to_string().map_err(|_| {
434        CliError::RefBundling(format!("decode json pointer fragment for {target_uri}"))
435    })?;
436    if pointer.is_empty() {
437        return Ok(document);
438    }
439    if !pointer.starts_with('/') {
440        return Err(CliError::RefBundling(format!(
441            "unsupported non-json-pointer fragment in {target_uri}"
442        )));
443    }
444
445    document.pointer(&pointer).cloned().ok_or_else(|| {
446        CliError::RefBundling(format!("json pointer {pointer} not found in {target_uri}"))
447    })
448}
449
450fn existing_definition_names(schema: &Value) -> BTreeSet<String> {
451    schema
452        .get("$defs")
453        .and_then(Value::as_object)
454        .map(|definitions| definitions.keys().cloned().collect())
455        .unwrap_or_default()
456}
457
458fn definition_ref(name: &str) -> Value {
459    Value::Object(Map::from_iter([(
460        "$ref".to_string(),
461        Value::String(format!("#/$defs/{name}")),
462    )]))
463}
464
465fn directory_file_uri(path: &Path) -> EngineResult<String> {
466    let path = if path.as_os_str().is_empty() {
467        Path::new(".")
468    } else {
469        path
470    };
471    let absolute = path.canonicalize().or_else(|_| std::path::absolute(path))?;
472    let uri = Url::from_directory_path(&absolute)
473        .map_err(|()| CliError::InvalidFileUriPath { path: absolute })?;
474    Ok(uri.into())
475}
476
477fn file_uri_path(uri: &Uri<String>) -> EngineResult<PathBuf> {
478    let invalid_uri = || CliError::InvalidFileUri {
479        uri: uri.as_str().to_string(),
480    };
481    Url::parse(uri.as_str())
482        .map_err(|_| invalid_uri())?
483        .to_file_path()
484        .map_err(|()| invalid_uri())
485}
486
487#[cfg(test)]
488#[path = "tests/flatten.rs"]
489mod tests;