use std::collections::BTreeMap;
use std::io::{Cursor, Read};
use std::path::Path;
const MAX_ENTRY_BYTES: u64 = 256 * 1024 * 1024;
const MAX_TOTAL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DarError {
#[error("reading DAR failed: {0}")]
Io(#[from] std::io::Error),
#[error("malformed DAR archive: {0}")]
Zip(#[from] zip::result::ZipError),
#[error("not a DAR ({0}) — expected a .dar built with `daml build`")]
NotADar(String),
#[error("invalid DAR manifest: {0}")]
Manifest(String),
#[error("archive entry `{name}` exceeds the {MAX_ENTRY_BYTES}-byte limit")]
EntryTooLarge {
name: String,
},
#[error(
"DAR decompresses to more than the {MAX_TOTAL_BYTES}-byte limit in total \
(reached at entry `{name}`)"
)]
ArchiveTooLarge {
name: String,
},
}
#[derive(Debug)]
pub struct Dar {
manifest: BTreeMap<String, String>,
dalfs: BTreeMap<String, Vec<u8>>,
}
impl Dar {
pub fn open(path: impl AsRef<Path>) -> Result<Self, DarError> {
let bytes = std::fs::read(path)?;
Self::from_bytes(&bytes)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, DarError> {
Self::read(bytes, MAX_TOTAL_BYTES)
}
fn read(bytes: &[u8], total_budget: u64) -> Result<Self, DarError> {
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
.map_err(|error| DarError::NotADar(error.to_string()))?;
let mut manifest = BTreeMap::new();
let mut saw_manifest = false;
let mut dalfs = BTreeMap::new();
let mut remaining = total_budget;
for index in 0..archive.len() {
let entry = archive.by_index(index)?;
if !entry.is_file() {
continue;
}
let name = entry.name().to_string();
let is_manifest = name == "META-INF/MANIFEST.MF";
let is_dalf = Path::new(&name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("dalf"));
if !is_manifest && !is_dalf {
continue;
}
let cap = MAX_ENTRY_BYTES.min(remaining);
let data = read_capped(entry, cap).map_err(|why| match why {
TooLarge::Io(error) => DarError::Io(error),
TooLarge::Refused if cap < MAX_ENTRY_BYTES => {
DarError::ArchiveTooLarge { name: name.clone() }
}
TooLarge::Refused => DarError::EntryTooLarge { name: name.clone() },
})?;
remaining -= data.len() as u64;
if is_manifest {
saw_manifest = true;
let text = String::from_utf8(data).map_err(|_| {
DarError::Manifest("META-INF/MANIFEST.MF is not valid UTF-8".to_string())
})?;
manifest = parse_manifest(&text);
} else {
dalfs.insert(name, data);
}
}
if manifest.is_empty() {
return Err(DarError::Manifest(if saw_manifest {
"META-INF/MANIFEST.MF carries no `Key: value` entries".to_string()
} else {
"no META-INF/MANIFEST.MF".to_string()
}));
}
if dalfs.is_empty() {
return Err(DarError::Manifest(
"archive contains no .dalf packages".to_string(),
));
}
Ok(Self { manifest, dalfs })
}
#[must_use]
pub fn name(&self) -> Option<&str> {
self.manifest.get("Name").map(String::as_str)
}
#[must_use]
pub fn sdk_version(&self) -> Option<&str> {
self.manifest.get("Sdk-Version").map(String::as_str)
}
fn main_dalf_path(&self) -> Result<&str, DarError> {
self.manifest
.get("Main-Dalf")
.map(String::as_str)
.ok_or_else(|| DarError::Manifest("manifest has no Main-Dalf".to_string()))
}
pub fn main_package_bytes(&self) -> Result<&[u8], DarError> {
let path = self.main_dalf_path()?;
self.dalfs
.get(path)
.map(Vec::as_slice)
.ok_or_else(|| DarError::Manifest(format!("main dalf `{path}` not in archive")))
}
pub fn package_paths(&self) -> impl Iterator<Item = &str> {
self.dalfs.keys().map(String::as_str)
}
pub fn package_bytes(&self) -> impl Iterator<Item = &[u8]> {
self.dalfs.values().map(Vec::as_slice)
}
pub fn package_entries(&self) -> impl Iterator<Item = (&str, &[u8])> {
self.dalfs
.iter()
.map(|(name, bytes)| (name.as_str(), bytes.as_slice()))
}
#[must_use]
pub fn package_count(&self) -> usize {
self.dalfs.len()
}
}
fn read_capped(mut entry: zip::read::ZipFile<'_>, cap: u64) -> Result<Vec<u8>, TooLarge> {
if entry.size() > cap {
return Err(TooLarge::Refused);
}
let mut data = Vec::new();
Read::take(&mut entry, cap + 1)
.read_to_end(&mut data)
.map_err(TooLarge::Io)?;
if data.len() as u64 > cap {
return Err(TooLarge::Refused);
}
Ok(data)
}
enum TooLarge {
Refused,
Io(std::io::Error),
}
fn parse_manifest(text: &str) -> BTreeMap<String, String> {
let mut logical: Vec<String> = Vec::new();
for raw in text.split('\n') {
let line = raw.strip_suffix('\r').unwrap_or(raw);
if let Some(continuation) = line.strip_prefix(' ')
&& let Some(last) = logical.last_mut()
{
last.push_str(continuation);
continue;
}
logical.push(line.to_string());
}
logical
.iter()
.filter_map(|line| {
let (key, value) = line.split_once(": ")?;
Some((key.to_string(), value.to_string()))
})
.collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn manifest_unwraps_continuation_lines() {
let text = concat!(
"Manifest-Version: 1.0\r\n",
"Name: splice-wallet-payments-0.1.14\r\n",
"Main-Dalf: pkg/main-part1\r\n",
" part2.dalf\r\n",
"Sdk-Version: 3.3.0\r\n",
);
let manifest = parse_manifest(text);
assert_eq!(
manifest.get("Name").unwrap(),
"splice-wallet-payments-0.1.14"
);
assert_eq!(manifest.get("Sdk-Version").unwrap(), "3.3.0");
assert_eq!(
manifest.get("Main-Dalf").unwrap(),
"pkg/main-part1part2.dalf"
);
}
fn zip_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
use std::io::Write as _;
let mut cursor = Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(&mut cursor);
for (name, bytes) in entries {
writer
.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(bytes).unwrap();
}
writer.finish().unwrap();
cursor.into_inner()
}
#[test]
fn hostile_inputs_yield_typed_errors_not_panics() {
assert!(matches!(
Dar::from_bytes(b"just some text"),
Err(DarError::NotADar(_))
));
assert!(matches!(Dar::from_bytes(b""), Err(DarError::NotADar(_))));
let no_manifest = zip_of(&[("pkg.dalf", b"\x01\x02")]);
assert!(
matches!(Dar::from_bytes(&no_manifest), Err(DarError::Manifest(m)) if m.contains("no META-INF"))
);
let empty_manifest = zip_of(&[
("META-INF/MANIFEST.MF", b"garbage without colon-space\n"),
("pkg.dalf", b"\x01"),
]);
assert!(
matches!(Dar::from_bytes(&empty_manifest), Err(DarError::Manifest(m)) if m.contains("no `Key: value`"))
);
let bad_utf8 = zip_of(&[("META-INF/MANIFEST.MF", &[0xFF, 0xFE, 0x00][..])]);
assert!(
matches!(Dar::from_bytes(&bad_utf8), Err(DarError::Manifest(m)) if m.contains("UTF-8"))
);
let jarlike = zip_of(&[(
"META-INF/MANIFEST.MF",
b"Manifest-Version: 1.0\nMain-Class: com.example.App\n".as_slice(),
)]);
assert!(
matches!(Dar::from_bytes(&jarlike), Err(DarError::Manifest(m)) if m.contains("no .dalf"))
);
}
#[test]
fn oversized_entries_are_rejected_by_the_decompression_cap() {
use std::io::Write as _;
let mut cursor = Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(&mut cursor);
writer
.start_file(
"META-INF/MANIFEST.MF",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
writer.write_all(b"Main-Dalf: bomb.dalf\n").unwrap();
writer
.start_file("bomb.dalf", zip::write::SimpleFileOptions::default())
.unwrap();
let zeros = vec![0_u8; 8 * 1024 * 1024];
for _ in 0..33 {
writer.write_all(&zeros).unwrap(); }
writer.finish().unwrap();
let bytes = cursor.into_inner();
assert!(matches!(
Dar::from_bytes(&bytes),
Err(DarError::EntryTooLarge { name }) if name == "bomb.dalf"
));
}
#[test]
fn many_legal_entries_that_together_exceed_the_budget_are_refused() {
use std::io::Write as _;
const ENTRY: usize = 512 * 1024;
const BUDGET: u64 = 1024 * 1024;
let mut cursor = Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(&mut cursor);
let options = zip::write::SimpleFileOptions::default();
writer.start_file("META-INF/MANIFEST.MF", options).unwrap();
writer.write_all(b"Main-Dalf: a.dalf\n").unwrap();
let zeros = vec![0_u8; ENTRY];
for name in ["a.dalf", "b.dalf", "c.dalf", "d.dalf"] {
writer.start_file(name, options).unwrap();
writer.write_all(&zeros).unwrap();
}
writer.finish().unwrap();
let bytes = cursor.into_inner();
assert!(bytes.len() < 64 * 1024, "archive is {} bytes", bytes.len());
let error = Dar::read(&bytes, BUDGET).expect_err("four entries exceed the budget");
assert!(
matches!(&error, DarError::ArchiveTooLarge { .. }),
"the total is what was exceeded, not any one entry: {error}"
);
assert!(error.to_string().contains("in total"), "{error}");
assert!(Dar::read(&bytes, BUDGET * 8).is_ok());
}
#[test]
fn a_real_dar_is_nowhere_near_the_budget() {
let Ok(path) = std::env::var("CANTON_TEST_DAR") else {
return;
};
let Ok(bytes) = std::fs::read(&path) else {
return;
};
let dar = Dar::from_bytes(&bytes).expect("a real DAR");
let total: usize = dar.package_bytes().map(<[u8]>::len).sum();
assert!(
(total as u64) < MAX_TOTAL_BYTES / 10,
"{path} decompresses to {total} bytes, within 10x of the budget"
);
}
#[test]
fn opening_a_real_dar_reads_the_manifest_and_main_package() {
let Ok(path) = std::env::var("CANTON_TEST_DAR") else {
eprintln!("skipping real-DAR test: set CANTON_TEST_DAR=/path/to/x.dar");
return;
};
let dar = Dar::open(&path).expect("open DAR");
assert!(dar.name().is_some(), "DAR should have a Name");
assert!(
dar.sdk_version().is_some(),
"DAR should have an Sdk-Version"
);
assert!(
dar.package_count() > 1,
"a real DAR bundles its deps (daml-prim/stdlib)"
);
let main = dar.main_package_bytes().expect("main package bytes");
assert!(!main.is_empty(), "main package should have LF bytes");
println!(
"DAR {} (sdk {}) — {} packages, main = {} bytes",
dar.name().unwrap(),
dar.sdk_version().unwrap(),
dar.package_count(),
main.len()
);
}
}