Skip to main content

haste_fhir_package/
tarball.rs

1use crate::{Manifest, Package, PackageError, PackageFile};
2use flate2::read::GzDecoder;
3use std::{
4    io::{self, Read},
5    path::{Component, Path},
6};
7
8/// Bounds on what reading one package may cost. A tarball is untrusted
9/// input, and a small gzip can expand to far more than it weighs.
10#[derive(Debug, Clone, Copy)]
11pub struct Limits {
12    /// The most bytes the tarball may expand to, headers included. It is
13    /// enforced while unpacking, so a package past it is never read whole.
14    pub max_uncompressed_bytes: u64,
15    /// The most entries (files, folders) it may hold.
16    pub max_files: usize,
17}
18
19/// Reads at most `remaining` bytes from `inner`, then fails, so an archive
20/// that expands past the limit is refused rather than read to the end.
21struct Bounded<R> {
22    inner: R,
23    remaining: u64,
24    exceeded: bool,
25}
26
27impl<R: Read> Read for Bounded<R> {
28    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
29        if self.remaining == 0 {
30            // Anything more is over the limit; a clean end reads nothing.
31            let mut probe = [0u8; 1];
32            if self.inner.read(&mut probe)? == 0 {
33                return Ok(0);
34            }
35            self.exceeded = true;
36            return Err(io::Error::other("package exceeds the size limit"));
37        }
38        let max = usize::try_from(self.remaining)
39            .unwrap_or(usize::MAX)
40            .min(buf.len());
41        let read = self.inner.read(&mut buf[..max])?;
42        self.remaining -= read as u64;
43        Ok(read)
44    }
45}
46
47/// A path inside the archive as plain components: `./package/x.json` and
48/// `package/x.json` alike. `None` for a path that climbs out (`..`) or is
49/// absolute, which no package needs.
50fn components(path: &Path) -> Option<Vec<String>> {
51    let mut parts = Vec::new();
52    for component in path.components() {
53        match component {
54            Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
55            Component::CurDir => {}
56            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
57        }
58    }
59    Some(parts)
60}
61
62/// A package name without an npm scope: a FHIR package republished on npm
63/// as `@scope/hl7.fhir.us.core` (as this repository's artifact packages
64/// are) is installed as `hl7.fhir.us.core`.
65fn unscoped(name: &str) -> &str {
66    let name = name.trim();
67    match name
68        .strip_prefix('@')
69        .and_then(|scoped| scoped.split_once('/'))
70    {
71        Some((_, name)) => name,
72        None => name,
73    }
74}
75
76/// Reads a package tarball, gzipped (`.tgz`, as registries serve them) or
77/// not.
78///
79/// The package folder is wherever `package.json` sits nearest the top, so a
80/// tarball made from any folder works, not only npm's `package/`. Only the
81/// JSON files directly in that folder and in its example folder are kept.
82///
83/// # Errors
84///
85/// When the archive is unreadable, breaks `limits`, or has no
86/// `package.json`.
87pub fn read(bytes: &[u8], limits: &Limits) -> Result<Package, PackageError> {
88    let gzipped = bytes.starts_with(&[0x1f, 0x8b]);
89    let decompressed: Box<dyn Read + '_> = if gzipped {
90        Box::new(GzDecoder::new(bytes))
91    } else {
92        Box::new(bytes)
93    };
94    let mut bounded = Bounded {
95        inner: decompressed,
96        remaining: limits.max_uncompressed_bytes,
97        exceeded: false,
98    };
99
100    let json_files = {
101        let mut archive = tar::Archive::new(&mut bounded);
102        collect_json_files(&mut archive, limits)
103    };
104    let json_files = match json_files {
105        Ok(files) => files,
106        Err(_) if bounded.exceeded => {
107            return Err(PackageError::TooLargeUnpacked(
108                limits.max_uncompressed_bytes,
109            ));
110        }
111        Err(error) => return Err(error),
112    };
113    let unpacked_bytes = limits.max_uncompressed_bytes - bounded.remaining;
114
115    // The package folder: package.json's, nearest the top.
116    let root = json_files
117        .iter()
118        .filter(|(parts, _)| parts.last().is_some_and(|name| name == "package.json"))
119        .map(|(parts, _)| parts[..parts.len() - 1].to_vec())
120        .min_by_key(Vec::len)
121        .ok_or(PackageError::MissingManifest)?;
122
123    let manifest_path = [root.as_slice(), &["package.json".to_string()]].concat();
124    let mut manifest = json_files
125        .iter()
126        .find(|(parts, _)| *parts == manifest_path)
127        .map(|(_, content)| serde_json::from_slice::<Manifest>(content))
128        .ok_or(PackageError::MissingManifest)?
129        .map_err(|e| PackageError::InvalidManifest(e.to_string()))?;
130    manifest.name = unscoped(&manifest.name).to_string();
131
132    let example_folder = manifest
133        .directories
134        .example
135        .clone()
136        .unwrap_or_else(|| "example".to_string());
137    let example_folder = components(Path::new(&example_folder)).unwrap_or_default();
138    let example_root = [root.as_slice(), example_folder.as_slice()].concat();
139
140    let mut resources = Vec::new();
141    let mut examples = Vec::new();
142    for (parts, content) in json_files {
143        let Some((name, folder)) = parts.split_last() else {
144            continue;
145        };
146        if folder == root.as_slice() {
147            if name == "package.json" || name.starts_with('.') {
148                continue;
149            }
150            resources.push(PackageFile {
151                path: name.clone(),
152                content,
153            });
154        } else if !example_folder.is_empty() && folder == example_root.as_slice() {
155            if name.starts_with('.') {
156                continue;
157            }
158            examples.push(PackageFile {
159                path: [example_folder.as_slice(), std::slice::from_ref(name)]
160                    .concat()
161                    .join("/"),
162                content,
163            });
164        }
165    }
166
167    Ok(Package {
168        manifest,
169        resources,
170        examples,
171        unpacked_bytes,
172    })
173}
174
175/// A file's path as components, and its content.
176type ArchiveFile = (Vec<String>, Vec<u8>);
177
178/// Every regular `.json` file in the archive no deeper than a package's
179/// example folder, as path components and content. Deeper files (`other/`,
180/// `openapi/`, ...) are skipped unread.
181fn collect_json_files<R: Read>(
182    archive: &mut tar::Archive<R>,
183    limits: &Limits,
184) -> Result<Vec<ArchiveFile>, PackageError> {
185    // A `package/example/x.json` path, under one extra top folder at most.
186    const MAX_DEPTH: usize = 4;
187
188    let unreadable = |e: io::Error| PackageError::Unreadable(e.to_string());
189    let mut files = Vec::new();
190    for (count, entry) in archive.entries().map_err(unreadable)?.enumerate() {
191        if count >= limits.max_files {
192            return Err(PackageError::TooManyFiles(limits.max_files));
193        }
194        let mut entry = entry.map_err(unreadable)?;
195        if !entry.header().entry_type().is_file() {
196            continue;
197        }
198        let Some(parts) = components(&entry.path().map_err(unreadable)?) else {
199            continue;
200        };
201        if parts.len() > MAX_DEPTH
202            || !parts
203                .last()
204                .is_some_and(|name| name.to_ascii_lowercase().ends_with(".json"))
205        {
206            continue;
207        }
208        let mut content = Vec::with_capacity(
209            usize::try_from(entry.header().size().unwrap_or(0))
210                .unwrap_or(0)
211                .min(1 << 20),
212        );
213        entry.read_to_end(&mut content).map_err(unreadable)?;
214        files.push((parts, content));
215    }
216    Ok(files)
217}
218
219/// Packs a package folder (one holding `package.json`) into a gzipped
220/// tarball laid out as npm packs one, under `package/`. Only what [`read`]
221/// uses is packed: the JSON files directly in the folder and in its example
222/// folder.
223///
224/// # Errors
225///
226/// When the folder has no readable `package.json` or a file cannot be read.
227pub fn pack(folder: &Path) -> Result<Vec<u8>, PackageError> {
228    let unreadable = |e: io::Error| PackageError::Unreadable(e.to_string());
229    let manifest = std::fs::read(folder.join("package.json")).map_err(|e| match e.kind() {
230        io::ErrorKind::NotFound => PackageError::MissingManifest,
231        _ => unreadable(e),
232    })?;
233    let manifest = serde_json::from_slice::<Manifest>(&manifest)
234        .map_err(|e| PackageError::InvalidManifest(e.to_string()))?;
235    let example_folder = manifest
236        .directories
237        .example
238        .unwrap_or_else(|| "example".to_string());
239
240    let json_files = |relative: &Path| -> Result<Vec<(String, std::path::PathBuf)>, PackageError> {
241        let directory = folder.join(relative);
242        if !directory.is_dir() {
243            return Ok(Vec::new());
244        }
245        let mut files = Vec::new();
246        for entry in std::fs::read_dir(&directory).map_err(unreadable)? {
247            let entry = entry.map_err(unreadable)?;
248            let name = entry.file_name().to_string_lossy().into_owned();
249            if entry.file_type().map_err(unreadable)?.is_file()
250                && name.to_ascii_lowercase().ends_with(".json")
251            {
252                let packed = Path::new("package").join(relative).join(&name);
253                files.push((packed.to_string_lossy().replace('\\', "/"), entry.path()));
254            }
255        }
256        files.sort();
257        Ok(files)
258    };
259    let mut files = json_files(Path::new(""))?;
260    files.extend(json_files(Path::new(&example_folder))?);
261
262    let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
263        Vec::new(),
264        flate2::Compression::default(),
265    ));
266    for (packed, path) in files {
267        let content = std::fs::read(&path).map_err(unreadable)?;
268        let mut header = tar::Header::new_gnu();
269        header.set_size(content.len() as u64);
270        header.set_mode(0o600);
271        builder
272            .append_data(&mut header, packed, content.as_slice())
273            .map_err(unreadable)?;
274    }
275    builder
276        .into_inner()
277        .and_then(flate2::write::GzEncoder::finish)
278        .map_err(unreadable)
279}