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 mut 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 declared = entry.size();
122            let data = read_capped(&mut entry, declared, cap).map_err(|why| match why {
123                TooLarge::Io(error) => DarError::Io(error),
124                // Which limit bit is decided here, where both are known: a cap
125                // the budget shrank means the archive as a whole is too big,
126                // and blaming this entry for it would be a lie.
127                TooLarge::Refused if cap < MAX_ENTRY_BYTES => {
128                    DarError::ArchiveTooLarge { name: name.clone() }
129                }
130                TooLarge::Refused => DarError::EntryTooLarge { name: name.clone() },
131            })?;
132            remaining -= data.len() as u64;
133
134            if is_manifest {
135                saw_manifest = true;
136                let text = String::from_utf8(data).map_err(|_| {
137                    DarError::Manifest("META-INF/MANIFEST.MF is not valid UTF-8".to_string())
138                })?;
139                manifest = parse_manifest(&text);
140            } else {
141                dalfs.insert(name, data);
142            }
143        }
144
145        if manifest.is_empty() {
146            return Err(DarError::Manifest(if saw_manifest {
147                "META-INF/MANIFEST.MF carries no `Key: value` entries".to_string()
148            } else {
149                "no META-INF/MANIFEST.MF".to_string()
150            }));
151        }
152        if dalfs.is_empty() {
153            return Err(DarError::Manifest(
154                "archive contains no .dalf packages".to_string(),
155            ));
156        }
157        Ok(Self { manifest, dalfs })
158    }
159
160    /// The package name (manifest `Name`), if present.
161    #[must_use]
162    pub fn name(&self) -> Option<&str> {
163        self.manifest.get("Name").map(String::as_str)
164    }
165
166    /// The Daml SDK version the DAR was built with (manifest `Sdk-Version`).
167    #[must_use]
168    pub fn sdk_version(&self) -> Option<&str> {
169        self.manifest.get("Sdk-Version").map(String::as_str)
170    }
171
172    /// The path of the main package's `.dalf` within the archive.
173    fn main_dalf_path(&self) -> Result<&str, DarError> {
174        self.manifest
175            .get("Main-Dalf")
176            .map(String::as_str)
177            .ok_or_else(|| DarError::Manifest("manifest has no Main-Dalf".to_string()))
178    }
179
180    /// The raw Daml-LF bytes of the **main** package (the DAR's own package, as
181    /// opposed to its `daml-prim` / `daml-stdlib` / transitive dependencies).
182    ///
183    /// # Errors
184    /// Returns [`DarError`] if the manifest does not name the main package or
185    /// the named entry is absent.
186    pub fn main_package_bytes(&self) -> Result<&[u8], DarError> {
187        let path = self.main_dalf_path()?;
188        self.dalfs
189            .get(path)
190            .map(Vec::as_slice)
191            .ok_or_else(|| DarError::Manifest(format!("main dalf `{path}` not in archive")))
192    }
193
194    /// The archive paths of every package (`.dalf`) in the DAR, main and
195    /// dependencies, in sorted order.
196    pub fn package_paths(&self) -> impl Iterator<Item = &str> {
197        self.dalfs.keys().map(String::as_str)
198    }
199
200    /// The raw Daml-LF bytes of **every** package in the DAR — the main package
201    /// and its full dependency closure (`daml-prim`, `daml-stdlib`, and any
202    /// other packages the DAR bundles). Decoding all of them gives the
203    /// PackageMap needed to resolve cross-package references.
204    pub fn package_bytes(&self) -> impl Iterator<Item = &[u8]> {
205        self.dalfs.values().map(Vec::as_slice)
206    }
207
208    /// Like [`Self::package_bytes`], each package paired with its archive path
209    /// — so a decode failure can name the offending `.dalf`.
210    pub fn package_entries(&self) -> impl Iterator<Item = (&str, &[u8])> {
211        self.dalfs
212            .iter()
213            .map(|(name, bytes)| (name.as_str(), bytes.as_slice()))
214    }
215
216    /// The number of packages (`.dalf` entries) in the DAR.
217    #[must_use]
218    pub fn package_count(&self) -> usize {
219        self.dalfs.len()
220    }
221}
222
223/// Read a zip entry fully, refusing to decompress more than `cap` bytes.
224///
225/// `cap` is checked against both the declared and the actual size: a hostile
226/// header can under-declare, so the declared size being small proves nothing.
227/// The caller decides what `cap` is — the per-entry ceiling, or whatever is
228/// left of the archive's budget when that is smaller — which is what keeps a
229/// single read from over-allocating past the total.
230///
231/// It takes the entry as any reader rather than as `zip`'s entry type: that
232/// type gained a type parameter in zip 2.6, inside the 2.x range this crate
233/// accepts, and naming it broke every build that resolved the newer zip.
234///
235/// `Err(TooLarge)` carries nothing: only the caller knows which of the two
236/// limits was the binding one, and saying the wrong one sends the reader to
237/// look at an entry that is not the problem.
238fn read_capped(entry: &mut impl Read, declared: u64, cap: u64) -> Result<Vec<u8>, TooLarge> {
239    if declared > cap {
240        return Err(TooLarge::Refused);
241    }
242    let mut data = Vec::new();
243    // `take` one byte past the cap: hitting it means the entry lied about its
244    // size and is over the limit.
245    Read::take(entry, cap + 1)
246        .read_to_end(&mut data)
247        .map_err(TooLarge::Io)?;
248    if data.len() as u64 > cap {
249        return Err(TooLarge::Refused);
250    }
251    Ok(data)
252}
253
254/// Why [`read_capped`] stopped: over the cap, or the read itself failed.
255enum TooLarge {
256    Refused,
257    Io(std::io::Error),
258}
259
260/// Parse a JAR-style manifest into key → value pairs, un-wrapping continuation
261/// lines (a line beginning with a single space continues the previous one — the
262/// manifest wraps values at 72 bytes).
263fn parse_manifest(text: &str) -> BTreeMap<String, String> {
264    let mut logical: Vec<String> = Vec::new();
265    for raw in text.split('\n') {
266        let line = raw.strip_suffix('\r').unwrap_or(raw);
267        if let Some(continuation) = line.strip_prefix(' ')
268            && let Some(last) = logical.last_mut()
269        {
270            last.push_str(continuation);
271            continue;
272        }
273        logical.push(line.to_string());
274    }
275
276    logical
277        .iter()
278        .filter_map(|line| {
279            let (key, value) = line.split_once(": ")?;
280            Some((key.to_string(), value.to_string()))
281        })
282        .collect()
283}
284
285#[cfg(test)]
286#[allow(clippy::unwrap_used, clippy::expect_used)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn manifest_unwraps_continuation_lines() {
292        // A `Main-Dalf` value wrapped across two lines — the continuation line
293        // begins with a single space (kept as separate literals so the space is
294        // not swallowed by Rust's `\`-continuation).
295        let text = concat!(
296            "Manifest-Version: 1.0\r\n",
297            "Name: splice-wallet-payments-0.1.14\r\n",
298            "Main-Dalf: pkg/main-part1\r\n",
299            " part2.dalf\r\n",
300            "Sdk-Version: 3.3.0\r\n",
301        );
302        let manifest = parse_manifest(text);
303        assert_eq!(
304            manifest.get("Name").unwrap(),
305            "splice-wallet-payments-0.1.14"
306        );
307        assert_eq!(manifest.get("Sdk-Version").unwrap(), "3.3.0");
308        // The wrapped Main-Dalf is stitched back together.
309        assert_eq!(
310            manifest.get("Main-Dalf").unwrap(),
311            "pkg/main-part1part2.dalf"
312        );
313    }
314
315    /// Build an in-memory zip from `(name, bytes)` entries.
316    fn zip_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
317        use std::io::Write as _;
318        let mut cursor = Cursor::new(Vec::new());
319        let mut writer = zip::ZipWriter::new(&mut cursor);
320        for (name, bytes) in entries {
321            writer
322                .start_file(*name, zip::write::SimpleFileOptions::default())
323                .unwrap();
324            writer.write_all(bytes).unwrap();
325        }
326        writer.finish().unwrap();
327        cursor.into_inner()
328    }
329
330    #[test]
331    fn hostile_inputs_yield_typed_errors_not_panics() {
332        // Not a zip at all.
333        assert!(matches!(
334            Dar::from_bytes(b"just some text"),
335            Err(DarError::NotADar(_))
336        ));
337        // Empty input.
338        assert!(matches!(Dar::from_bytes(b""), Err(DarError::NotADar(_))));
339        // A zip with no manifest.
340        let no_manifest = zip_of(&[("pkg.dalf", b"\x01\x02")]);
341        assert!(
342            matches!(Dar::from_bytes(&no_manifest), Err(DarError::Manifest(m)) if m.contains("no META-INF"))
343        );
344        // A manifest that parses to nothing gets a distinct message.
345        let empty_manifest = zip_of(&[
346            ("META-INF/MANIFEST.MF", b"garbage without colon-space\n"),
347            ("pkg.dalf", b"\x01"),
348        ]);
349        assert!(
350            matches!(Dar::from_bytes(&empty_manifest), Err(DarError::Manifest(m)) if m.contains("no `Key: value`"))
351        );
352        // A non-UTF8 manifest is a manifest error, not a raw IO error.
353        let bad_utf8 = zip_of(&[("META-INF/MANIFEST.MF", &[0xFF, 0xFE, 0x00][..])]);
354        assert!(
355            matches!(Dar::from_bytes(&bad_utf8), Err(DarError::Manifest(m)) if m.contains("UTF-8"))
356        );
357        // A manifest-bearing zip with zero .dalf packages (e.g. a plain JAR).
358        let jarlike = zip_of(&[(
359            "META-INF/MANIFEST.MF",
360            b"Manifest-Version: 1.0\nMain-Class: com.example.App\n".as_slice(),
361        )]);
362        assert!(
363            matches!(Dar::from_bytes(&jarlike), Err(DarError::Manifest(m)) if m.contains("no .dalf"))
364        );
365    }
366
367    #[test]
368    fn oversized_entries_are_rejected_by_the_decompression_cap() {
369        // A stored (uncompressed) entry cannot cheaply exceed the cap in a unit
370        // test, so exercise the declared-size branch via a deflated entry that
371        // inflates far beyond its compressed size — 512 MiB of zeros compresses
372        // to ~half a MB but must still be rejected.
373        use std::io::Write as _;
374        let mut cursor = Cursor::new(Vec::new());
375        let mut writer = zip::ZipWriter::new(&mut cursor);
376        writer
377            .start_file(
378                "META-INF/MANIFEST.MF",
379                zip::write::SimpleFileOptions::default(),
380            )
381            .unwrap();
382        writer.write_all(b"Main-Dalf: bomb.dalf\n").unwrap();
383        writer
384            .start_file("bomb.dalf", zip::write::SimpleFileOptions::default())
385            .unwrap();
386        let zeros = vec![0_u8; 8 * 1024 * 1024];
387        for _ in 0..33 {
388            writer.write_all(&zeros).unwrap(); // 264 MiB declared — over the cap
389        }
390        writer.finish().unwrap();
391        let bytes = cursor.into_inner();
392
393        assert!(matches!(
394            Dar::from_bytes(&bytes),
395            Err(DarError::EntryTooLarge { name }) if name == "bomb.dalf"
396        ));
397    }
398
399    /// The shape a per-entry cap cannot see: every entry is legal on its own.
400    ///
401    /// Each `.dalf` here is well under any per-entry ceiling, and there is
402    /// nothing to object to until they are added up — which is the whole point,
403    /// because every one of them is read into memory. Zeros DEFLATE at roughly
404    /// 1000:1, so the archive that asks for all of it arrives as a small file.
405    #[test]
406    fn many_legal_entries_that_together_exceed_the_budget_are_refused() {
407        use std::io::Write as _;
408
409        const ENTRY: usize = 512 * 1024;
410        const BUDGET: u64 = 1024 * 1024; // room for two entries, not four
411
412        let mut cursor = Cursor::new(Vec::new());
413        let mut writer = zip::ZipWriter::new(&mut cursor);
414        let options = zip::write::SimpleFileOptions::default();
415        writer.start_file("META-INF/MANIFEST.MF", options).unwrap();
416        writer.write_all(b"Main-Dalf: a.dalf\n").unwrap();
417        let zeros = vec![0_u8; ENTRY];
418        for name in ["a.dalf", "b.dalf", "c.dalf", "d.dalf"] {
419            writer.start_file(name, options).unwrap();
420            writer.write_all(&zeros).unwrap();
421        }
422        writer.finish().unwrap();
423        let bytes = cursor.into_inner();
424
425        // Small enough to hand around unnoticed — that is what makes it worth
426        // guarding against rather than assuming nobody would send it.
427        assert!(bytes.len() < 64 * 1024, "archive is {} bytes", bytes.len());
428
429        let error = Dar::read(&bytes, BUDGET).expect_err("four entries exceed the budget");
430        assert!(
431            matches!(&error, DarError::ArchiveTooLarge { .. }),
432            "the total is what was exceeded, not any one entry: {error}"
433        );
434        // And it says so: blaming a single 512 KiB entry would send the reader
435        // to look at something that is not the problem.
436        assert!(error.to_string().contains("in total"), "{error}");
437
438        // The same archive is fine when the budget covers it, so the guard is
439        // not simply refusing everything.
440        assert!(Dar::read(&bytes, BUDGET * 8).is_ok());
441    }
442
443    /// The real corpus has to keep working: a guard that fires on a legitimate
444    /// DAR is worse than no guard, because it is the one people meet.
445    #[test]
446    fn a_real_dar_is_nowhere_near_the_budget() {
447        let Ok(path) = std::env::var("CANTON_TEST_DAR") else {
448            return;
449        };
450        let Ok(bytes) = std::fs::read(&path) else {
451            return;
452        };
453        let dar = Dar::from_bytes(&bytes).expect("a real DAR");
454        let total: usize = dar.package_bytes().map(<[u8]>::len).sum();
455        assert!(
456            (total as u64) < MAX_TOTAL_BYTES / 10,
457            "{path} decompresses to {total} bytes, within 10x of the budget"
458        );
459    }
460
461    #[test]
462    fn opening_a_real_dar_reads_the_manifest_and_main_package() {
463        // Env-gated: point at a real .dar (e.g. one from cn-quickstart).
464        let Ok(path) = std::env::var("CANTON_TEST_DAR") else {
465            eprintln!("skipping real-DAR test: set CANTON_TEST_DAR=/path/to/x.dar");
466            return;
467        };
468
469        let dar = Dar::open(&path).expect("open DAR");
470        assert!(dar.name().is_some(), "DAR should have a Name");
471        assert!(
472            dar.sdk_version().is_some(),
473            "DAR should have an Sdk-Version"
474        );
475        assert!(
476            dar.package_count() > 1,
477            "a real DAR bundles its deps (daml-prim/stdlib)"
478        );
479        let main = dar.main_package_bytes().expect("main package bytes");
480        assert!(!main.is_empty(), "main package should have LF bytes");
481        println!(
482            "DAR {} (sdk {}) — {} packages, main = {} bytes",
483            dar.name().unwrap(),
484            dar.sdk_version().unwrap(),
485            dar.package_count(),
486            main.len()
487        );
488    }
489}