Skip to main content

canton_lf/
dar.rs

1//! The DAR **container** layer: a `.dar` is a JAR-style zip of `.dalf`
2//! (Daml-LF-encoded) packages plus a `META-INF/MANIFEST.MF` that names the main
3//! package and lists the dependency packages.
4//!
5//! This module reads the container and hands out the raw package bytes; turning
6//! those bytes into a typed AST (the Daml-LF protobuf decode) is a later step.
7
8use std::collections::BTreeMap;
9use std::io::{Cursor, Read};
10use std::path::Path;
11
12/// The most a single archive entry may decompress to (256 MiB). Real `.dalf`
13/// payloads are a few MB; the cap stops a hostile "zip bomb" DAR from
14/// ballooning a small file into gigabytes of memory. (The official JVM reader
15/// applies the same kind of per-entry threshold.)
16const MAX_ENTRY_BYTES: u64 = 256 * 1024 * 1024;
17
18/// The most an **entire** DAR may decompress to (2 GiB).
19///
20/// A per-entry cap alone bounds nothing: every `.dalf` is read into memory, so
21/// an archive of a thousand entries each just under [`MAX_ENTRY_BYTES`] passes
22/// every individual check and still asks for hundreds of gigabytes — and
23/// because zeros DEFLATE at roughly 1000:1, the file on disk stays small enough
24/// to arrive over a network without notice.
25///
26/// The ceiling is far above anything real: the largest available corpus is
27/// 41 MiB of package payload across 18 DARs, so this is ~50× the whole of it in
28/// a single archive.
29const MAX_TOTAL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
30
31/// An error opening or reading a DAR.
32#[derive(Debug, thiserror::Error)]
33#[non_exhaustive]
34pub enum DarError {
35    /// The file could not be read.
36    #[error("reading DAR failed: {0}")]
37    Io(#[from] std::io::Error),
38    /// The zip container is malformed.
39    #[error("malformed DAR archive: {0}")]
40    Zip(#[from] zip::result::ZipError),
41    /// The bytes are not a DAR at all (not a zip archive).
42    #[error("not a DAR ({0}) — expected a .dar built with `daml build`")]
43    NotADar(String),
44    /// The manifest was missing or did not name the main package.
45    #[error("invalid DAR manifest: {0}")]
46    Manifest(String),
47    /// An archive entry exceeds the decompression cap.
48    #[error("archive entry `{name}` exceeds the {MAX_ENTRY_BYTES}-byte limit")]
49    EntryTooLarge {
50        /// The offending entry's path within the archive.
51        name: String,
52    },
53    /// The archive's entries decompress to more, in total, than the
54    /// archive-wide limit — no single one of them need be oversized.
55    #[error(
56        "DAR decompresses to more than the {MAX_TOTAL_BYTES}-byte limit in total \
57         (reached at entry `{name}`)"
58    )]
59    ArchiveTooLarge {
60        /// The entry the budget ran out on. Not the culprit on its own — the
61        /// ones before it spent the budget — but the place to start looking.
62        name: String,
63    },
64}
65
66/// A parsed DAR: its manifest fields plus the raw bytes of each `.dalf`.
67#[derive(Debug)]
68pub struct Dar {
69    manifest: BTreeMap<String, String>,
70    dalfs: BTreeMap<String, Vec<u8>>,
71}
72
73impl Dar {
74    /// Read a DAR from a file on disk.
75    ///
76    /// # Errors
77    /// Returns [`DarError`] if the file cannot be read or is not a valid DAR.
78    pub fn open(path: impl AsRef<Path>) -> Result<Self, DarError> {
79        let bytes = std::fs::read(path)?;
80        Self::from_bytes(&bytes)
81    }
82
83    /// Read a DAR from an in-memory byte buffer.
84    ///
85    /// # Errors
86    /// Returns [`DarError`] if the bytes are not a valid DAR.
87    pub fn from_bytes(bytes: &[u8]) -> Result<Self, DarError> {
88        Self::read(bytes, MAX_TOTAL_BYTES)
89    }
90
91    /// The reader, with the archive-wide budget as a parameter so a test can
92    /// exercise the exhaustion path on kilobytes instead of gigabytes. Every
93    /// caller outside the tests passes [`MAX_TOTAL_BYTES`].
94    fn read(bytes: &[u8], total_budget: u64) -> Result<Self, DarError> {
95        let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
96            .map_err(|error| DarError::NotADar(error.to_string()))?;
97
98        let mut manifest = BTreeMap::new();
99        let mut saw_manifest = false;
100        let mut dalfs = BTreeMap::new();
101        // What is left of the archive-wide budget. Every entry read is capped
102        // by whichever of this and the per-entry ceiling is smaller, so a read
103        // can never allocate past the total even once.
104        let mut remaining = total_budget;
105
106        for index in 0..archive.len() {
107            let entry = archive.by_index(index)?;
108            if !entry.is_file() {
109                continue;
110            }
111            let name = entry.name().to_string();
112            let is_manifest = name == "META-INF/MANIFEST.MF";
113            let is_dalf = Path::new(&name)
114                .extension()
115                .is_some_and(|ext| ext.eq_ignore_ascii_case("dalf"));
116            if !is_manifest && !is_dalf {
117                continue;
118            }
119
120            let cap = MAX_ENTRY_BYTES.min(remaining);
121            let data = read_capped(entry, cap).map_err(|why| match why {
122                TooLarge::Io(error) => DarError::Io(error),
123                // Which limit bit is decided here, where both are known: a cap
124                // the budget shrank means the archive as a whole is too big,
125                // and blaming this entry for it would be a lie.
126                TooLarge::Refused if cap < MAX_ENTRY_BYTES => {
127                    DarError::ArchiveTooLarge { name: name.clone() }
128                }
129                TooLarge::Refused => DarError::EntryTooLarge { name: name.clone() },
130            })?;
131            remaining -= data.len() as u64;
132
133            if is_manifest {
134                saw_manifest = true;
135                let text = String::from_utf8(data).map_err(|_| {
136                    DarError::Manifest("META-INF/MANIFEST.MF is not valid UTF-8".to_string())
137                })?;
138                manifest = parse_manifest(&text);
139            } else {
140                dalfs.insert(name, data);
141            }
142        }
143
144        if manifest.is_empty() {
145            return Err(DarError::Manifest(if saw_manifest {
146                "META-INF/MANIFEST.MF carries no `Key: value` entries".to_string()
147            } else {
148                "no META-INF/MANIFEST.MF".to_string()
149            }));
150        }
151        if dalfs.is_empty() {
152            return Err(DarError::Manifest(
153                "archive contains no .dalf packages".to_string(),
154            ));
155        }
156        Ok(Self { manifest, dalfs })
157    }
158
159    /// The package name (manifest `Name`), if present.
160    #[must_use]
161    pub fn name(&self) -> Option<&str> {
162        self.manifest.get("Name").map(String::as_str)
163    }
164
165    /// The Daml SDK version the DAR was built with (manifest `Sdk-Version`).
166    #[must_use]
167    pub fn sdk_version(&self) -> Option<&str> {
168        self.manifest.get("Sdk-Version").map(String::as_str)
169    }
170
171    /// The path of the main package's `.dalf` within the archive.
172    fn main_dalf_path(&self) -> Result<&str, DarError> {
173        self.manifest
174            .get("Main-Dalf")
175            .map(String::as_str)
176            .ok_or_else(|| DarError::Manifest("manifest has no Main-Dalf".to_string()))
177    }
178
179    /// The raw Daml-LF bytes of the **main** package (the DAR's own package, as
180    /// opposed to its `daml-prim` / `daml-stdlib` / transitive dependencies).
181    ///
182    /// # Errors
183    /// Returns [`DarError`] if the manifest does not name the main package or
184    /// the named entry is absent.
185    pub fn main_package_bytes(&self) -> Result<&[u8], DarError> {
186        let path = self.main_dalf_path()?;
187        self.dalfs
188            .get(path)
189            .map(Vec::as_slice)
190            .ok_or_else(|| DarError::Manifest(format!("main dalf `{path}` not in archive")))
191    }
192
193    /// The archive paths of every package (`.dalf`) in the DAR, main and
194    /// dependencies, in sorted order.
195    pub fn package_paths(&self) -> impl Iterator<Item = &str> {
196        self.dalfs.keys().map(String::as_str)
197    }
198
199    /// The raw Daml-LF bytes of **every** package in the DAR — the main package
200    /// and its full dependency closure (`daml-prim`, `daml-stdlib`, and any
201    /// other packages the DAR bundles). Decoding all of them gives the
202    /// PackageMap needed to resolve cross-package references.
203    pub fn package_bytes(&self) -> impl Iterator<Item = &[u8]> {
204        self.dalfs.values().map(Vec::as_slice)
205    }
206
207    /// Like [`Self::package_bytes`], each package paired with its archive path
208    /// — so a decode failure can name the offending `.dalf`.
209    pub fn package_entries(&self) -> impl Iterator<Item = (&str, &[u8])> {
210        self.dalfs
211            .iter()
212            .map(|(name, bytes)| (name.as_str(), bytes.as_slice()))
213    }
214
215    /// The number of packages (`.dalf` entries) in the DAR.
216    #[must_use]
217    pub fn package_count(&self) -> usize {
218        self.dalfs.len()
219    }
220}
221
222/// Read a zip entry fully, refusing to decompress more than `cap` bytes.
223///
224/// `cap` is checked against both the declared and the actual size: a hostile
225/// header can under-declare, so the declared size being small proves nothing.
226/// The caller decides what `cap` is — the per-entry ceiling, or whatever is
227/// left of the archive's budget when that is smaller — which is what keeps a
228/// single read from over-allocating past the total.
229///
230/// `Err(TooLarge)` carries nothing: only the caller knows which of the two
231/// limits was the binding one, and saying the wrong one sends the reader to
232/// look at an entry that is not the problem.
233fn read_capped(mut entry: zip::read::ZipFile<'_>, cap: u64) -> Result<Vec<u8>, TooLarge> {
234    if entry.size() > cap {
235        return Err(TooLarge::Refused);
236    }
237    let mut data = Vec::new();
238    // `take` one byte past the cap: hitting it means the entry lied about its
239    // size and is over the limit.
240    Read::take(&mut entry, cap + 1)
241        .read_to_end(&mut data)
242        .map_err(TooLarge::Io)?;
243    if data.len() as u64 > cap {
244        return Err(TooLarge::Refused);
245    }
246    Ok(data)
247}
248
249/// Why [`read_capped`] stopped: over the cap, or the read itself failed.
250enum TooLarge {
251    Refused,
252    Io(std::io::Error),
253}
254
255/// Parse a JAR-style manifest into key → value pairs, un-wrapping continuation
256/// lines (a line beginning with a single space continues the previous one — the
257/// manifest wraps values at 72 bytes).
258fn parse_manifest(text: &str) -> BTreeMap<String, String> {
259    let mut logical: Vec<String> = Vec::new();
260    for raw in text.split('\n') {
261        let line = raw.strip_suffix('\r').unwrap_or(raw);
262        if let Some(continuation) = line.strip_prefix(' ')
263            && let Some(last) = logical.last_mut()
264        {
265            last.push_str(continuation);
266            continue;
267        }
268        logical.push(line.to_string());
269    }
270
271    logical
272        .iter()
273        .filter_map(|line| {
274            let (key, value) = line.split_once(": ")?;
275            Some((key.to_string(), value.to_string()))
276        })
277        .collect()
278}
279
280#[cfg(test)]
281#[allow(clippy::unwrap_used, clippy::expect_used)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn manifest_unwraps_continuation_lines() {
287        // A `Main-Dalf` value wrapped across two lines — the continuation line
288        // begins with a single space (kept as separate literals so the space is
289        // not swallowed by Rust's `\`-continuation).
290        let text = concat!(
291            "Manifest-Version: 1.0\r\n",
292            "Name: splice-wallet-payments-0.1.14\r\n",
293            "Main-Dalf: pkg/main-part1\r\n",
294            " part2.dalf\r\n",
295            "Sdk-Version: 3.3.0\r\n",
296        );
297        let manifest = parse_manifest(text);
298        assert_eq!(
299            manifest.get("Name").unwrap(),
300            "splice-wallet-payments-0.1.14"
301        );
302        assert_eq!(manifest.get("Sdk-Version").unwrap(), "3.3.0");
303        // The wrapped Main-Dalf is stitched back together.
304        assert_eq!(
305            manifest.get("Main-Dalf").unwrap(),
306            "pkg/main-part1part2.dalf"
307        );
308    }
309
310    /// Build an in-memory zip from `(name, bytes)` entries.
311    fn zip_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
312        use std::io::Write as _;
313        let mut cursor = Cursor::new(Vec::new());
314        let mut writer = zip::ZipWriter::new(&mut cursor);
315        for (name, bytes) in entries {
316            writer
317                .start_file(*name, zip::write::SimpleFileOptions::default())
318                .unwrap();
319            writer.write_all(bytes).unwrap();
320        }
321        writer.finish().unwrap();
322        cursor.into_inner()
323    }
324
325    #[test]
326    fn hostile_inputs_yield_typed_errors_not_panics() {
327        // Not a zip at all.
328        assert!(matches!(
329            Dar::from_bytes(b"just some text"),
330            Err(DarError::NotADar(_))
331        ));
332        // Empty input.
333        assert!(matches!(Dar::from_bytes(b""), Err(DarError::NotADar(_))));
334        // A zip with no manifest.
335        let no_manifest = zip_of(&[("pkg.dalf", b"\x01\x02")]);
336        assert!(
337            matches!(Dar::from_bytes(&no_manifest), Err(DarError::Manifest(m)) if m.contains("no META-INF"))
338        );
339        // A manifest that parses to nothing gets a distinct message.
340        let empty_manifest = zip_of(&[
341            ("META-INF/MANIFEST.MF", b"garbage without colon-space\n"),
342            ("pkg.dalf", b"\x01"),
343        ]);
344        assert!(
345            matches!(Dar::from_bytes(&empty_manifest), Err(DarError::Manifest(m)) if m.contains("no `Key: value`"))
346        );
347        // A non-UTF8 manifest is a manifest error, not a raw IO error.
348        let bad_utf8 = zip_of(&[("META-INF/MANIFEST.MF", &[0xFF, 0xFE, 0x00][..])]);
349        assert!(
350            matches!(Dar::from_bytes(&bad_utf8), Err(DarError::Manifest(m)) if m.contains("UTF-8"))
351        );
352        // A manifest-bearing zip with zero .dalf packages (e.g. a plain JAR).
353        let jarlike = zip_of(&[(
354            "META-INF/MANIFEST.MF",
355            b"Manifest-Version: 1.0\nMain-Class: com.example.App\n".as_slice(),
356        )]);
357        assert!(
358            matches!(Dar::from_bytes(&jarlike), Err(DarError::Manifest(m)) if m.contains("no .dalf"))
359        );
360    }
361
362    #[test]
363    fn oversized_entries_are_rejected_by_the_decompression_cap() {
364        // A stored (uncompressed) entry cannot cheaply exceed the cap in a unit
365        // test, so exercise the declared-size branch via a deflated entry that
366        // inflates far beyond its compressed size — 512 MiB of zeros compresses
367        // to ~half a MB but must still be rejected.
368        use std::io::Write as _;
369        let mut cursor = Cursor::new(Vec::new());
370        let mut writer = zip::ZipWriter::new(&mut cursor);
371        writer
372            .start_file(
373                "META-INF/MANIFEST.MF",
374                zip::write::SimpleFileOptions::default(),
375            )
376            .unwrap();
377        writer.write_all(b"Main-Dalf: bomb.dalf\n").unwrap();
378        writer
379            .start_file("bomb.dalf", zip::write::SimpleFileOptions::default())
380            .unwrap();
381        let zeros = vec![0_u8; 8 * 1024 * 1024];
382        for _ in 0..33 {
383            writer.write_all(&zeros).unwrap(); // 264 MiB declared — over the cap
384        }
385        writer.finish().unwrap();
386        let bytes = cursor.into_inner();
387
388        assert!(matches!(
389            Dar::from_bytes(&bytes),
390            Err(DarError::EntryTooLarge { name }) if name == "bomb.dalf"
391        ));
392    }
393
394    /// The shape a per-entry cap cannot see: every entry is legal on its own.
395    ///
396    /// Each `.dalf` here is well under any per-entry ceiling, and there is
397    /// nothing to object to until they are added up — which is the whole point,
398    /// because every one of them is read into memory. Zeros DEFLATE at roughly
399    /// 1000:1, so the archive that asks for all of it arrives as a small file.
400    #[test]
401    fn many_legal_entries_that_together_exceed_the_budget_are_refused() {
402        use std::io::Write as _;
403
404        const ENTRY: usize = 512 * 1024;
405        const BUDGET: u64 = 1024 * 1024; // room for two entries, not four
406
407        let mut cursor = Cursor::new(Vec::new());
408        let mut writer = zip::ZipWriter::new(&mut cursor);
409        let options = zip::write::SimpleFileOptions::default();
410        writer.start_file("META-INF/MANIFEST.MF", options).unwrap();
411        writer.write_all(b"Main-Dalf: a.dalf\n").unwrap();
412        let zeros = vec![0_u8; ENTRY];
413        for name in ["a.dalf", "b.dalf", "c.dalf", "d.dalf"] {
414            writer.start_file(name, options).unwrap();
415            writer.write_all(&zeros).unwrap();
416        }
417        writer.finish().unwrap();
418        let bytes = cursor.into_inner();
419
420        // Small enough to hand around unnoticed — that is what makes it worth
421        // guarding against rather than assuming nobody would send it.
422        assert!(bytes.len() < 64 * 1024, "archive is {} bytes", bytes.len());
423
424        let error = Dar::read(&bytes, BUDGET).expect_err("four entries exceed the budget");
425        assert!(
426            matches!(&error, DarError::ArchiveTooLarge { .. }),
427            "the total is what was exceeded, not any one entry: {error}"
428        );
429        // And it says so: blaming a single 512 KiB entry would send the reader
430        // to look at something that is not the problem.
431        assert!(error.to_string().contains("in total"), "{error}");
432
433        // The same archive is fine when the budget covers it, so the guard is
434        // not simply refusing everything.
435        assert!(Dar::read(&bytes, BUDGET * 8).is_ok());
436    }
437
438    /// The real corpus has to keep working: a guard that fires on a legitimate
439    /// DAR is worse than no guard, because it is the one people meet.
440    #[test]
441    fn a_real_dar_is_nowhere_near_the_budget() {
442        let Ok(path) = std::env::var("CANTON_TEST_DAR") else {
443            return;
444        };
445        let Ok(bytes) = std::fs::read(&path) else {
446            return;
447        };
448        let dar = Dar::from_bytes(&bytes).expect("a real DAR");
449        let total: usize = dar.package_bytes().map(<[u8]>::len).sum();
450        assert!(
451            (total as u64) < MAX_TOTAL_BYTES / 10,
452            "{path} decompresses to {total} bytes, within 10x of the budget"
453        );
454    }
455
456    #[test]
457    fn opening_a_real_dar_reads_the_manifest_and_main_package() {
458        // Env-gated: point at a real .dar (e.g. one from cn-quickstart).
459        let Ok(path) = std::env::var("CANTON_TEST_DAR") else {
460            eprintln!("skipping real-DAR test: set CANTON_TEST_DAR=/path/to/x.dar");
461            return;
462        };
463
464        let dar = Dar::open(&path).expect("open DAR");
465        assert!(dar.name().is_some(), "DAR should have a Name");
466        assert!(
467            dar.sdk_version().is_some(),
468            "DAR should have an Sdk-Version"
469        );
470        assert!(
471            dar.package_count() > 1,
472            "a real DAR bundles its deps (daml-prim/stdlib)"
473        );
474        let main = dar.main_package_bytes().expect("main package bytes");
475        assert!(!main.is_empty(), "main package should have LF bytes");
476        println!(
477            "DAR {} (sdk {}) — {} packages, main = {} bytes",
478            dar.name().unwrap(),
479            dar.sdk_version().unwrap(),
480            dar.package_count(),
481            main.len()
482        );
483    }
484}