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}