Skip to main content

ferrobridge_validator/package/
mod.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! One FHIR package read offline, from a `.tgz` file or an unpacked directory.
5//!
6//! A package is a gzipped tarball whose `package` folder holds the
7//! `package.json` manifest and the resources, with an optional `.index.json`
8//! derived from them (<https://hl7.org/fhir/packages.html>, §2.1.10.1 to
9//! §2.1.10.4). A directory is read as the unpacked form of the same tarball:
10//! `<dir>/package/package.json` and the resources beside it. Only the files
11//! directly in `package/` are read; the folders below it (examples, other)
12//! carry no definitions the validator resolves.
13
14mod archive;
15
16use std::collections::BTreeMap;
17use std::path::Path;
18use std::path::PathBuf;
19use std::sync::Arc;
20
21use serde::Deserialize;
22
23/// The resource types a package indexes, so the set can resolve them by
24/// canonical: the definitions, and the value sets and code systems a slice
25/// discriminator reads its codes from.
26const INDEXED: &[&str] = &["StructureDefinition", "ValueSet", "CodeSystem"];
27
28/// The manifest file of a package.
29const MANIFEST: &str = "package.json";
30
31/// The optional index file of a package.
32const INDEX: &str = ".index.json";
33
34/// The UTF-8 byte-order mark.
35const BOM: &[u8] = b"\xEF\xBB\xBF";
36
37/// One package: its manifest and the definitions it carries.
38#[derive(Debug, Clone)]
39pub struct Package {
40    name: String,
41    version: String,
42    dependencies: BTreeMap<String, String>,
43    source: PathBuf,
44    resources: Vec<Resource>,
45}
46
47/// One indexed resource of a package.
48#[derive(Debug, Clone)]
49pub(crate) struct Resource {
50    /// The file name inside `package/`.
51    pub(crate) file: String,
52    /// The `resourceType`.
53    pub(crate) type_name: String,
54    /// The canonical URL, when the resource has one.
55    pub(crate) url: Option<String>,
56    /// The business version, when the resource has one.
57    pub(crate) version: Option<String>,
58    /// Where its content is.
59    pub(crate) content: Content,
60}
61
62/// The content of a resource: held, or a file read on first use.
63#[derive(Debug, Clone)]
64pub(crate) enum Content {
65    /// The bytes, without a byte-order mark.
66    Bytes(Arc<[u8]>),
67    /// A file of an unpacked package.
68    File(PathBuf),
69}
70
71impl Content {
72    /// Returns the bytes, without a byte-order mark.
73    pub(crate) fn read(&self) -> std::io::Result<Arc<[u8]>> {
74        match self {
75            Self::Bytes(bytes) => Ok(Arc::clone(bytes)),
76            Self::File(path) => Ok(Arc::from(strip_bom(&std::fs::read(path)?))),
77        }
78    }
79}
80
81/// Why a package could not be read.
82#[derive(Debug, thiserror::Error)]
83pub enum PackageError {
84    /// A file or directory could not be read.
85    #[error("cannot read {path}")]
86    Io {
87        /// The path that failed.
88        path: PathBuf,
89        /// The I/O error.
90        #[source]
91        source: std::io::Error,
92    },
93    /// The path is neither a `.tgz` file nor a directory.
94    #[error("{path} is neither a .tgz package nor a directory")]
95    NotAPackage {
96        /// The path given.
97        path: PathBuf,
98    },
99    /// The package holds no `package/package.json`.
100    #[error("{path} holds no package/package.json")]
101    NoManifest {
102        /// The archive or directory.
103        path: PathBuf,
104    },
105    /// A JSON file of the package does not parse.
106    #[error("{file} of the package at {path} is not the JSON a package carries")]
107    Json {
108        /// The archive or directory.
109        path: PathBuf,
110        /// The file inside `package/`.
111        file: String,
112        /// The parse error.
113        #[source]
114        source: serde_json::Error,
115    },
116    /// `.index.json` names a file the package does not hold.
117    #[error(".index.json of the package at {path} names {file}, which the package does not hold")]
118    IndexedFileMissing {
119        /// The archive or directory.
120        path: PathBuf,
121        /// The file the index names.
122        file: String,
123    },
124}
125
126/// The fields of `package.json` the set reads.
127#[derive(Debug, Deserialize)]
128struct Manifest {
129    name: String,
130    version: String,
131    dependencies: Option<BTreeMap<String, String>>,
132}
133
134/// The fields of `.index.json` the set reads.
135#[derive(Debug, Deserialize)]
136struct Index {
137    files: Vec<IndexEntry>,
138}
139
140/// One row of `.index.json`.
141#[derive(Debug, Deserialize)]
142struct IndexEntry {
143    filename: String,
144    #[serde(rename = "resourceType")]
145    type_name: String,
146    url: Option<String>,
147    version: Option<String>,
148}
149
150/// The fields of a resource the set indexes it by.
151#[derive(Debug, Deserialize)]
152struct Header {
153    #[serde(rename = "resourceType")]
154    type_name: Option<String>,
155    url: Option<String>,
156    version: Option<String>,
157}
158
159/// The JSON files directly inside `package/`.
160trait Files {
161    /// Returns the bytes of `file`, `None` when the package holds no such file.
162    fn read(&mut self, file: &str) -> Result<Option<Vec<u8>>, PackageError>;
163
164    /// Returns the content of `file` without reading a file of a directory,
165    /// `None` when the package holds no such file.
166    fn content(&mut self, file: &str) -> Result<Option<Content>, PackageError>;
167
168    /// Returns the names of every JSON file, sorted.
169    fn names(&mut self) -> Result<Vec<String>, PackageError>;
170}
171
172impl Package {
173    /// Reads the package at `path`: a `.tgz` file or a directory holding
174    /// `package/package.json`.
175    ///
176    /// With a `.index.json`, the resources are indexed from it and a file of
177    /// a directory is read on first use; without one, every JSON file is
178    /// read for its `resourceType`, `url` and `version`.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`PackageError`] when the path is no package, a file cannot be
183    /// read, a manifest, index or resource does not parse, or the index names
184    /// a file the package does not hold.
185    pub fn read(path: &Path) -> Result<Self, PackageError> {
186        let metadata = std::fs::metadata(path).map_err(|source| PackageError::Io {
187            path: path.to_owned(),
188            source,
189        })?;
190        if metadata.is_dir() {
191            let mut files = Directory {
192                root: path.join("package"),
193            };
194            Self::from_files(path, &mut files)
195        } else if metadata.is_file() {
196            let mut files = archive::Archive::read(path)?;
197            Self::from_files(path, &mut files)
198        } else {
199            Err(PackageError::NotAPackage {
200                path: path.to_owned(),
201            })
202        }
203    }
204
205    /// Reads the manifest and the indexed resources from `files`.
206    fn from_files(path: &Path, files: &mut dyn Files) -> Result<Self, PackageError> {
207        let manifest_bytes = files
208            .read(MANIFEST)?
209            .ok_or_else(|| PackageError::NoManifest {
210                path: path.to_owned(),
211            })?;
212        let manifest: Manifest = parse(path, MANIFEST, &manifest_bytes)?;
213        let resources = match files.read(INDEX)? {
214            Some(bytes) => indexed(path, files, &parse::<Index>(path, INDEX, &bytes)?)?,
215            None => scanned(path, files)?,
216        };
217        Ok(Self {
218            name: manifest.name,
219            version: manifest.version,
220            dependencies: manifest.dependencies.unwrap_or_default(),
221            source: path.to_owned(),
222            resources,
223        })
224    }
225
226    /// Returns the package name from its manifest.
227    #[must_use]
228    pub fn name(&self) -> &str {
229        &self.name
230    }
231
232    /// Returns the package version from its manifest.
233    #[must_use]
234    pub fn version(&self) -> &str {
235        &self.version
236    }
237
238    /// Returns the dependencies its manifest names, package name to version.
239    #[must_use]
240    pub const fn dependencies(&self) -> &BTreeMap<String, String> {
241        &self.dependencies
242    }
243
244    /// Returns the path the package was read from.
245    #[must_use]
246    pub fn source(&self) -> &Path {
247        &self.source
248    }
249
250    /// Returns the number of `StructureDefinition` resources the package
251    /// carries.
252    #[must_use]
253    pub fn structure_definitions(&self) -> usize {
254        self.resources
255            .iter()
256            .filter(|resource| resource.type_name == "StructureDefinition")
257            .count()
258    }
259
260    /// Returns the indexed resources.
261    pub(crate) fn resources(&self) -> &[Resource] {
262        &self.resources
263    }
264}
265
266/// The resources `.index.json` names.
267///
268/// The index holds "no independent information" (packages.html §2.1.10.4),
269/// so its `resourceType`, `url` and `version` stand for the files'.
270fn indexed(
271    path: &Path,
272    files: &mut dyn Files,
273    index: &Index,
274) -> Result<Vec<Resource>, PackageError> {
275    let mut resources = Vec::new();
276    for entry in &index.files {
277        if !INDEXED.contains(&entry.type_name.as_str()) {
278            continue;
279        }
280        let Some(content) = files.content(&entry.filename)? else {
281            return Err(PackageError::IndexedFileMissing {
282                path: path.to_owned(),
283                file: entry.filename.clone(),
284            });
285        };
286        resources.push(Resource {
287            file: entry.filename.clone(),
288            type_name: entry.type_name.clone(),
289            url: entry.url.clone(),
290            version: entry.version.clone(),
291            content,
292        });
293    }
294    Ok(resources)
295}
296
297/// The resources of a package without an index, each file read for its
298/// header.
299fn scanned(path: &Path, files: &mut dyn Files) -> Result<Vec<Resource>, PackageError> {
300    let mut resources = Vec::new();
301    for file in files.names()? {
302        if file == MANIFEST || file == INDEX {
303            continue;
304        }
305        let Some(bytes) = files.read(&file)? else {
306            continue;
307        };
308        let header: Header = parse(path, &file, &bytes)?;
309        let Some(type_name) = header.type_name else {
310            continue;
311        };
312        if !INDEXED.contains(&type_name.as_str()) {
313            continue;
314        }
315        resources.push(Resource {
316            file,
317            type_name,
318            url: header.url,
319            version: header.version,
320            content: Content::Bytes(Arc::from(strip_bom(&bytes))),
321        });
322    }
323    Ok(resources)
324}
325
326/// Strips a leading UTF-8 byte-order mark.
327///
328/// No specification governs this: our own design. RFC 8259 §8.1 forbids the
329/// mark in JSON, and published FHIR packages carry it anyway.
330pub(crate) fn strip_bom(bytes: &[u8]) -> &[u8] {
331    bytes.strip_prefix(BOM).unwrap_or(bytes)
332}
333
334/// Parses `bytes`, the file `file` of the package at `path`.
335fn parse<T: serde::de::DeserializeOwned>(
336    path: &Path,
337    file: &str,
338    bytes: &[u8],
339) -> Result<T, PackageError> {
340    serde_json::from_slice(strip_bom(bytes)).map_err(|source| PackageError::Json {
341        path: path.to_owned(),
342        file: file.to_owned(),
343        source,
344    })
345}
346
347/// The `package/` folder of an unpacked package, read file by file.
348struct Directory {
349    root: PathBuf,
350}
351
352impl Files for Directory {
353    fn read(&mut self, file: &str) -> Result<Option<Vec<u8>>, PackageError> {
354        let path = self.root.join(file);
355        match std::fs::read(&path) {
356            Ok(bytes) => Ok(Some(bytes)),
357            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
358            Err(source) => Err(PackageError::Io { path, source }),
359        }
360    }
361
362    fn content(&mut self, file: &str) -> Result<Option<Content>, PackageError> {
363        let path = self.root.join(file);
364        match std::fs::metadata(&path) {
365            Ok(metadata) if !metadata.is_dir() => Ok(Some(Content::File(path))),
366            Ok(_) => Ok(None),
367            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
368            Err(source) => Err(PackageError::Io { path, source }),
369        }
370    }
371
372    fn names(&mut self) -> Result<Vec<String>, PackageError> {
373        let io = |source| PackageError::Io {
374            path: self.root.clone(),
375            source,
376        };
377        let mut names = Vec::new();
378        for entry in std::fs::read_dir(&self.root).map_err(io)? {
379            let entry = entry.map_err(io)?;
380            if entry.file_type().map_err(io)?.is_dir() {
381                continue;
382            }
383            if let Some(name) = entry.file_name().to_str()
384                && Path::new(name)
385                    .extension()
386                    .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
387            {
388                names.push(name.to_owned());
389            }
390        }
391        names.sort();
392        Ok(names)
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::strip_bom;
399
400    #[test]
401    fn a_leading_byte_order_mark_is_stripped() {
402        assert_eq!(strip_bom(b"\xEF\xBB\xBF{}"), b"{}");
403        assert_eq!(strip_bom(b"{}"), b"{}");
404    }
405}