zstd_chunked/
lib.rs

1//! A library to help read zstd:chunked files
2mod format;
3
4use core::ops::Range;
5use std::{
6    collections::HashMap,
7    io::{Read, Write},
8};
9
10use anyhow::{Context, Result, ensure};
11use zstd::stream::Decoder;
12
13use self::format::{Footer, FooterReference, Manifest, TarSplitEntry};
14
15/// A reference to a compressed range in a zstd:chunked file, along with size and checksum
16/// information about the uncompressed data at that range.
17#[derive(Debug, Clone)]
18pub struct ContentReference {
19    /// The range itself, in bytes, in the compressed file.
20    pub range: Range<u64>,
21
22    /// The digest of the data at the range, after decompression.
23    pub digest: String,
24
25    /// The size of the compressed data at the range, after decompression.
26    pub size: u64,
27}
28
29/// A chunk of data in a zstd:chunked stream.  Either contains inline data or a reference to a
30/// compressed range (and checksum and size information about the data at that range).
31#[derive(Debug, Clone)]
32pub enum Chunk {
33    /// The literal data appears directly.
34    Inline(Box<[u8]>),
35    /// The data appears at the referenced range, which may need to be fetched and decompressed.
36    External(Box<[ContentReference]>),
37}
38
39/// Represents the layout of a zstd:chunked file.  You can reconstruct the original file contents
40/// by iterating over the chunks.
41#[derive(Debug)]
42pub struct Stream {
43    /// The chunks in the file.
44    pub chunks: Vec<Chunk>,
45}
46
47impl Stream {
48    /// Create the metadata structure from the compressed frames referred to by the ranges in the
49    /// OCI layer descriptor annotations or the file footer.
50    ///
51    /// # Errors
52    ///
53    /// This function can fail if any of the metadata isn't in the expected format (zstd-compressed
54    /// JSON) or if there are missing mandatory fields or internal inconsistencies.  In all cases,
55    /// it indicates a corrupt zstd:chunked file (or a bug in the library).
56    pub fn new_from_frames(manifest: impl Read, tarsplit: impl Read) -> Result<Self> {
57        let manifest: Manifest = serde_json::from_reader(Decoder::new(manifest)?)?;
58
59        ensure!(
60            manifest.version == 1,
61            "Incorrect zstd:chunked CRFS manifest version"
62        );
63
64        // Read the manifest entries into a table by filename, taking only the ones that have the
65        // digest, size, offset and end_offset information filled in (ie: regular files).  Don't
66        // handle chunks.
67        let manifest_entries: HashMap<String, ContentReference> = manifest
68            .entries
69            .into_iter()
70            .filter_map(|entry| {
71                Some((
72                    entry.name,
73                    ContentReference {
74                        digest: entry.digest?,
75                        size: entry.size?,
76                        range: entry.offset?..entry.end_offset?,
77                    },
78                ))
79            })
80            .collect();
81
82        // Iterate over the chunks in the tarsplit.  For inline chunks, store the inline data.  For
83        // external chunks, look them up in the manifest_entries and store what we find.
84        let chunks = serde_json::Deserializer::from_reader(Decoder::new(tarsplit)?)
85            .into_iter()
86            .map(|entry| {
87                Ok(match entry? {
88                TarSplitEntry {
89                    name: Some(name),
90                    size: Some(size),
91                    ..  // ignored: crc64
92                } => {
93                    let reference = manifest_entries.get(&name)
94                        .with_context(|| format!("Filename {name} in zstd:chunked tarsplit missing from manifest"))?;
95                    ensure!(size == reference.size, "size mismatch");
96                    Some(Chunk::External(Box::from([reference.clone()])))
97                }
98                TarSplitEntry {
99                    payload: Some(payload),
100                    ..
101                } => Some(Chunk::Inline(payload)),
102                _ => None,
103            })
104            })
105            .filter_map(Result::transpose)
106            .collect::<Result<Vec<_>>>()?;
107
108        Ok(Self { chunks })
109    }
110
111    /// Iterates over all of the references that need to be satisfied for this stream to be
112    /// reconstructed.  This might be useful to help prefetch the required items.
113    pub fn references(&self) -> impl Iterator<Item = &ContentReference> {
114        self.chunks.iter().flat_map(|chunk| {
115            if let Chunk::External(items) = chunk {
116                items.as_ref()
117            } else {
118                &[]
119            }
120        })
121    }
122
123    /// Writes the content of the stream to the given writer.  The `resolve_reference()` function
124    /// should return the *decompressed* data corresponding to the reference.
125    ///
126    /// # Errors
127    ///
128    /// This function can fail only in response to external errors: a failure of the
129    /// `resolve_reference()` function or a failure to write to the writer.
130    pub fn write_to(
131        &self,
132        write: &mut impl Write,
133        resolve_reference: impl Fn(&ContentReference) -> Result<Vec<u8>>,
134    ) -> Result<()> {
135        for chunk in &self.chunks {
136            match chunk {
137                Chunk::Inline(data) => {
138                    write.write_all(data)?;
139                }
140                Chunk::External(refs) => {
141                    for r#ref in refs {
142                        write.write_all(&resolve_reference(r#ref)?)?;
143                    }
144                }
145            }
146        }
147        Ok(())
148    }
149}
150
151/// A reference to file metadata, either the manifest or the tarsplit
152pub struct MetadataReference {
153    /// The range itself, in bytes, in the compressed file.
154    pub range: Range<u64>,
155
156    /// The digest of the data at the range, *before* decompression.  This will be missing if we
157    /// read from the file footer or if the OCI annotations didn't provide it.
158    pub digest: Option<String>,
159
160    /// The size of the compressed data at the range, after decompression.
161    pub uncompressed_size: u64,
162}
163
164impl MetadataReference {
165    const fn from_footer(value: &FooterReference) -> Self {
166        let start = value.offset.get();
167        let end = start + value.length_compressed.get();
168
169        Self {
170            range: start..end,
171            digest: None,
172            uncompressed_size: value.length_uncompressed.get(),
173        }
174    }
175}
176
177/// References to the manifest and tarsplit metadata.  You can read these from the file footer or
178/// from the annotations on the OCI layer descriptor.
179pub struct MetadataReferences {
180    /// The location of the manifest data
181    pub manifest: MetadataReference,
182    /// The location of the tarsplit data
183    pub tarsplit: MetadataReference,
184}
185
186fn to_vec_u64(value: &str) -> Option<Vec<u64>> {
187    value.split(':').map(|s| s.parse().ok()).collect()
188}
189
190impl MetadataReferences {
191    /// Read the metadata references from the file footer.  The provided data can be any suffix of
192    /// the file, but it must be at least 72 bytes in length (to contain the footer).  Returns None
193    /// if this doesn't appear to be a zstd:chunked file.
194    #[must_use]
195    pub fn from_footer(suffix: &[u8]) -> Option<Self> {
196        let footer = Footer::from_suffix(suffix)?;
197        Some(Self {
198            manifest: MetadataReference::from_footer(&footer.manifest),
199            tarsplit: MetadataReference::from_footer(&footer.tarsplit),
200        })
201    }
202
203    /// Parses the metadata references from OCI layer descriptor annotations.  The type of the
204    /// annotations `HashMap` was chosen to be compatible with the oci-spec crate.  Returns None if
205    /// this doesn't appear to be a zstd:chunked layer descriptor.
206    #[allow(clippy::needless_pass_by_value)]
207    #[must_use]
208    pub fn from_oci(annotations: HashMap<String, String>) -> Option<Self> {
209        let manifest_digest =
210            annotations.get("io.github.containers.zstd-chunked.manifest-checksum");
211        let manifest_position =
212            annotations.get("io.github.containers.zstd-chunked.manifest-position")?;
213        let tarsplit_digest =
214            annotations.get("io.github.containers.zstd-chunked.tarsplit-checksum");
215        let tarsplit_position =
216            annotations.get("io.github.containers.zstd-chunked.tarsplit-position")?;
217
218        Some(Self {
219            manifest: match to_vec_u64(manifest_position)?.as_slice() {
220                &[start, length, uncompressed_size, 1] => MetadataReference {
221                    range: start..(start + length),
222                    digest: manifest_digest.cloned(),
223                    uncompressed_size,
224                },
225                _ => None?,
226            },
227            tarsplit: match to_vec_u64(tarsplit_position)?.as_slice() {
228                &[start, length, uncompressed_size] => MetadataReference {
229                    range: start..(start + length),
230                    digest: tarsplit_digest.cloned(),
231                    uncompressed_size,
232                },
233                _ => None?,
234            },
235        })
236    }
237}