canton_lf/decode.rs
1//! Decode a package's Daml-LF bytes into the LF 2 AST ([`Package`]), and resolve
2//! its interned package metadata — the name and version that drive SCU.
3//!
4//! LF 2.x stores every name and literal in the package's `interned_strings` /
5//! `interned_dotted_names` tables and references them by `i32` index, so the raw
6//! prost AST is index-heavy; [`interned_str`] resolves those indices.
7
8use prost::Message;
9
10use crate::dar::{Dar, DarError};
11use crate::pb::daml_lf_2::Package;
12use crate::pb::daml_lf_dev::{Archive, ArchivePayload, HashFunction, archive_payload};
13use sha2::{Digest as _, Sha256};
14
15/// An error decoding Daml-LF package bytes.
16#[derive(Debug, thiserror::Error)]
17#[non_exhaustive]
18pub enum DecodeError {
19 /// Reading the DAR container failed.
20 #[error(transparent)]
21 Dar(#[from] DarError),
22 /// The protobuf bytes were malformed.
23 #[error("malformed Daml-LF archive: {0}")]
24 Proto(#[from] prost::DecodeError),
25 /// The package is not Daml-LF 2.x (this decoder only supports LF 2.x).
26 #[error("unsupported Daml-LF version (only LF 2.x is supported)")]
27 UnsupportedVersion,
28 /// The archive's payload does not hash to the package id it declares.
29 /// Refused: that id is the package's identity — it is what generated
30 /// bindings embed as `PACKAGE_ID` and what cross-package references resolve
31 /// through — so accepting a mismatch means addressing a package by a name
32 /// its contents do not answer to.
33 #[error("archive payload does not match its declared package id {declared}")]
34 PackageIdMismatch {
35 /// The id the archive claims.
36 declared: String,
37 /// The SHA-256 of the payload actually present.
38 computed: String,
39 },
40 /// The archive declares a hash function this build does not implement.
41 #[error("unsupported archive hash function {0} (only SHA-256 is defined)")]
42 UnsupportedHashFunction(i32),
43 /// The package is LF 2.x, but a **minor** this decoder was not built
44 /// against. Refused rather than decoded: prost drops fields it does not
45 /// know, so a newer minor would otherwise yield silently incomplete
46 /// bindings — a template missing a field decodes, compiles, and then fails
47 /// on the wire.
48 #[error(
49 "Daml-LF 2.{minor} is not a version this build was compiled against \
50 (it reads {supported}); the LF protos are pinned to a Canton release, \
51 so use a DAR built for that release or upgrade the SDK"
52 )]
53 UnsupportedMinor {
54 /// The minor version the archive declares.
55 minor: String,
56 /// The versions this build accepts, comma-separated and fully spelled
57 /// (`"2.1, 2.2"`). Spelled in full rather than as bare minors because
58 /// the message renders it verbatim: a `2.` prefix in the template
59 /// would attach to the first entry only.
60 supported: String,
61 },
62 /// A decode failure attributed to a specific package in the DAR.
63 #[error("package `{name}`: {source}")]
64 InPackage {
65 /// The archive path of the `.dalf` that failed to decode.
66 name: String,
67 /// The underlying failure.
68 source: Box<DecodeError>,
69 },
70}
71
72/// Decode the DAR's **main** package into the Daml-LF 2 AST, together with its
73/// **package id** (the `Archive` hash — the input to SCU/PackageMap and to
74/// qualifying cross-package references).
75///
76/// # Errors
77/// Returns [`DecodeError`] if the container or protobuf bytes are malformed, or
78/// the package is not Daml-LF 2.x.
79pub fn decode_main_package(dar: &Dar) -> Result<(Package, String), DecodeError> {
80 decode_package(dar.main_package_bytes()?)
81}
82
83/// Decode a single package's `.dalf` bytes (an `Archive`) into the LF 2 AST and
84/// its package id (the archive hash).
85///
86/// # Errors
87/// See [`decode_main_package`].
88pub fn decode_package(archive_bytes: &[u8]) -> Result<(Package, String), DecodeError> {
89 let archive = Archive::decode(archive_bytes)?;
90 let package_id = verified_package_id(&archive)?;
91 Ok((decode_payload(&archive.payload)?, package_id))
92}
93
94/// Decode an `ArchivePayload` — the *inner* half of an archive, without the
95/// hash beside it.
96///
97/// Which half you have depends on where the package came from. A DAR entry is a
98/// whole `Archive`: payload, hash and hash function together, which is why
99/// [`decode_package`] can check the id against the bytes. The Ledger API's
100/// `GetPackage` hands the three back as separate response fields, so a caller
101/// there holds only this — and does the check itself, against the id it asked
102/// for. Two shapes, and mistaking one for the other fails with a proto error
103/// about a `hash` field that says nothing about the cause.
104///
105/// # Errors
106/// [`DecodeError::Proto`] if the bytes are not an `ArchivePayload`,
107/// [`DecodeError::UnsupportedVersion`] if the package is not Daml-LF 2.x, and
108/// [`DecodeError::UnsupportedMinor`] for an LF 2 minor this build cannot read.
109pub fn decode_payload(payload_bytes: &[u8]) -> Result<Package, DecodeError> {
110 let payload = ArchivePayload::decode(payload_bytes)?;
111 // Major first: a minor only means something once the major is LF 2. An LF 1
112 // archive — which is every DAR a Daml 2.x SDK built — carries minors like
113 // "15", and judging it by the minor gate would tell the reader that
114 // "Daml-LF 2.15" is unsupported and to upgrade the SDK. No version of this
115 // decoder will ever read it; the major is the thing to say.
116 let Some(archive_payload::Sum::DamlLf2(package)) = payload.sum else {
117 return Err(DecodeError::UnsupportedVersion);
118 };
119 check_minor(&payload.minor)?;
120 Ok(package)
121}
122
123/// The package id of an archive, checked against its contents rather than
124/// taken on trust.
125///
126/// The id is the SHA-256 of the payload, and it is load-bearing: generated
127/// bindings embed it as `Contract::PACKAGE_ID`, and every cross-package
128/// reference in the DAR resolves through it. A DAR whose declared id does not
129/// match its bytes — corrupted in transit, or assembled by hand — would
130/// otherwise produce bindings that name a package by an id its contents do not
131/// answer to, and nothing downstream would notice.
132fn verified_package_id(archive: &Archive) -> Result<String, DecodeError> {
133 // The enum has exactly one member today; anything else is a scheme this
134 // build cannot check, and an unchecked hash is the thing being fixed here.
135 if archive.hash_function != HashFunction::Sha256 as i32 {
136 return Err(DecodeError::UnsupportedHashFunction(archive.hash_function));
137 }
138
139 let computed = hex(&Sha256::digest(&archive.payload));
140 if computed == archive.hash {
141 Ok(computed)
142 } else {
143 Err(DecodeError::PackageIdMismatch {
144 declared: archive.hash.clone(),
145 computed,
146 })
147 }
148}
149
150/// Lower-case hex, the spelling Daml-LF uses for a package id.
151fn hex(bytes: &[u8]) -> String {
152 bytes
153 .iter()
154 .fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
155 use std::fmt::Write as _;
156 // Infallible: writing to a String cannot fail.
157 let _ = write!(out, "{byte:02x}");
158 out
159 })
160}
161
162/// The Daml-LF 2.x minor versions this build decodes.
163///
164/// The list is evidence, not aspiration: a survey of every DAR available here —
165/// 18 of them, 648 packages across the Splice amulet/wallet/token-standard and
166/// quickstart-licensing sets — finds 617 packages at `2.1` and 31 at `2.2`, and
167/// both spellings appear inside DARs this repo generates bindings from. Both
168/// decode correctly against the vendored schema, which the conformance oracle
169/// checks against the official JVM `daml-lf-archive` reader.
170///
171/// Adding a minor is a deliberate act with a procedure: re-vendor the protos if
172/// the new minor changes them, then re-run the oracle. Guessing instead means
173/// prost silently drops fields belonging to a schema it does not know, and the
174/// bindings come out quietly incomplete.
175const SUPPORTED_LF2_MINORS: &[&str] = &["1", "2"];
176
177/// Refuse a minor we were not built against.
178///
179/// `"dev"` is refused with everything else on purpose — it is the unstable
180/// spelling, and the whole point of this gate is that an unknown schema must
181/// not decode silently.
182fn check_minor(minor: &str) -> Result<(), DecodeError> {
183 if SUPPORTED_LF2_MINORS.contains(&minor) {
184 return Ok(());
185 }
186 Err(DecodeError::UnsupportedMinor {
187 minor: minor.to_string(),
188 supported: SUPPORTED_LF2_MINORS
189 .iter()
190 .map(|minor| format!("2.{minor}"))
191 .collect::<Vec<_>>()
192 .join(", "),
193 })
194}
195
196/// Decode **every** package in a DAR, each paired with its package id (archive
197/// hash) — the PackageMap for resolving cross-package references.
198///
199/// # Errors
200/// Returns [`DecodeError`] if any package's bytes are malformed or not LF 2.x.
201pub fn decode_all(dar: &Dar) -> Result<Vec<(String, Package)>, DecodeError> {
202 dar.package_entries()
203 .map(|(name, bytes)| {
204 let (package, id) = decode_package(bytes).map_err(|error| DecodeError::InPackage {
205 name: name.to_string(),
206 source: Box::new(error),
207 })?;
208 Ok((id, package))
209 })
210 .collect()
211}
212
213/// Resolve a `package_import_id` — an index into the package's explicit import
214/// table (`package_imports.imported_packages`) — to the target package's id
215/// hash. Newer LF (2.dev / SDK 3.5+) references imported packages this way
216/// instead of by an interned id string.
217#[must_use]
218pub fn imported_package_id(package: &Package, index: i32) -> Option<&str> {
219 let imports = package.package_imports.as_ref()?;
220 let i = usize::try_from(index).ok()?;
221 imports.imported_packages.get(i).map(String::as_str)
222}
223
224/// Resolve an interned string by its `i32` index into a package's
225/// `interned_strings` table.
226#[must_use]
227pub fn interned_str(package: &Package, index: i32) -> Option<&str> {
228 usize::try_from(index)
229 .ok()
230 .and_then(|i| package.interned_strings.get(i))
231 .map(String::as_str)
232}
233
234/// Resolve an interned dotted name (a module or type name) to its `.`-joined
235/// form, e.g. `"Licensing.AppInstall"`.
236#[must_use]
237pub fn interned_dotted_name(package: &Package, index: i32) -> Option<String> {
238 let dotted = usize::try_from(index)
239 .ok()
240 .and_then(|i| package.interned_dotted_names.get(i))?;
241 let segments = dotted
242 .segments_interned_str
243 .iter()
244 .map(|&segment| interned_str(package, segment))
245 .collect::<Option<Vec<_>>>()?;
246 Some(segments.join("."))
247}
248
249/// Resolve an interned type by its `i32` index into a package's `interned_types`
250/// table (LF 2.x stores types once and references them by index).
251#[must_use]
252pub fn interned_type(package: &Package, index: i32) -> Option<&crate::pb::daml_lf_2::Type> {
253 usize::try_from(index)
254 .ok()
255 .and_then(|i| package.interned_types.get(i))
256}
257
258/// The package name from its metadata (`PackageMetadata.name_interned_str`).
259#[must_use]
260pub fn package_name(package: &Package) -> Option<&str> {
261 let index = package.metadata.as_ref()?.name_interned_str;
262 interned_str(package, index)
263}
264
265/// The package version from its metadata (`PackageMetadata.version_interned_str`).
266#[must_use]
267pub fn package_version(package: &Package) -> Option<&str> {
268 let index = package.metadata.as_ref()?.version_interned_str;
269 interned_str(package, index)
270}
271
272#[cfg(test)]
273#[allow(clippy::unwrap_used, clippy::expect_used)]
274mod hash_tests {
275 use super::*;
276
277 /// An archive whose declared id is the real SHA-256 of its payload.
278 fn honest_archive() -> Archive {
279 let payload = ArchivePayload {
280 minor: "1".to_string(),
281 sum: Some(archive_payload::Sum::DamlLf2(Package::default())),
282 }
283 .encode_to_vec();
284 let hash = hex(&Sha256::digest(&payload));
285 Archive {
286 hash_function: HashFunction::Sha256 as i32,
287 payload,
288 hash,
289 }
290 }
291
292 #[test]
293 fn an_archive_that_hashes_to_its_id_decodes_and_returns_that_id() {
294 let archive = honest_archive();
295 let expected = archive.hash.clone();
296 let (_package, id) = decode_package(&archive.encode_to_vec()).expect("honest archive");
297 assert_eq!(id, expected);
298 assert_eq!(id.len(), 64, "a SHA-256 in hex");
299 }
300
301 /// The id is the package's identity: generated bindings embed it as
302 /// `PACKAGE_ID` and cross-package references resolve through it. Taking a
303 /// mismatched one on trust means addressing a package by a name its
304 /// contents do not answer to.
305 #[test]
306 fn a_payload_that_does_not_match_its_declared_id_is_refused() {
307 let mut archive = honest_archive();
308 archive.hash = "0".repeat(64);
309 let err = decode_package(&archive.encode_to_vec()).expect_err("must not decode");
310 assert!(
311 matches!(err, DecodeError::PackageIdMismatch { .. }),
312 "{err}"
313 );
314 // Both sides belong in the message: without them a reader cannot tell a
315 // corrupted download from a hand-assembled DAR.
316 let message = err.to_string();
317 assert!(message.contains(&"0".repeat(64)), "{message}");
318
319 // The same when the payload is what changed, which is the realistic
320 // shape of the failure.
321 let mut tampered = honest_archive();
322 tampered.payload.extend_from_slice(b"extra");
323 assert!(matches!(
324 decode_package(&tampered.encode_to_vec()),
325 Err(DecodeError::PackageIdMismatch { .. })
326 ));
327 }
328
329 #[test]
330 fn an_unknown_hash_function_is_refused_rather_than_assumed_to_be_sha256() {
331 let mut archive = honest_archive();
332 archive.hash_function = 7;
333 let err = decode_package(&archive.encode_to_vec()).expect_err("must not decode");
334 assert!(
335 matches!(err, DecodeError::UnsupportedHashFunction(7)),
336 "{err}"
337 );
338 }
339}
340
341#[cfg(test)]
342#[allow(clippy::unwrap_used, clippy::expect_used)]
343mod minor_tests {
344 use super::*;
345
346 /// Build a **well-formed** `Archive` declaring `minor`: the id is the real
347 /// SHA-256 of the payload, so these tests exercise the minor gate rather
348 /// than tripping the hash gate that runs before it.
349 fn archive_with_minor(minor: &str) -> Vec<u8> {
350 let payload = ArchivePayload {
351 minor: minor.to_string(),
352 sum: Some(archive_payload::Sum::DamlLf2(Package::default())),
353 }
354 .encode_to_vec();
355 let hash = hex(&Sha256::digest(&payload));
356 Archive {
357 hash_function: HashFunction::Sha256 as i32,
358 payload,
359 hash,
360 }
361 .encode_to_vec()
362 }
363
364 /// An LF **1.x** archive is the likeliest wrong input there is — every DAR
365 /// built by a Daml 2.x SDK is one — so it has to be told the truth: this
366 /// decoder handles LF 2 only. Diagnosing it by minor instead would print
367 /// "unsupported Daml-LF minor 2.15 … upgrade the SDK", sending the reader
368 /// to rebuild a DAR whose major version will never be right.
369 #[test]
370 fn an_lf1_archive_is_diagnosed_by_its_major_not_its_minor() {
371 let payload = ArchivePayload {
372 // LF 1.15 was the last LF 1 minor, so this is a real DAR's shape.
373 minor: "15".to_string(),
374 sum: Some(archive_payload::Sum::DamlLf1(vec![1, 2, 3])),
375 }
376 .encode_to_vec();
377 let hash = hex(&Sha256::digest(&payload));
378 let bytes = Archive {
379 hash_function: HashFunction::Sha256 as i32,
380 payload,
381 hash,
382 }
383 .encode_to_vec();
384
385 let err = decode_package(&bytes).expect_err("LF 1 is not decodable here");
386 let message = err.to_string();
387 assert!(
388 matches!(err, DecodeError::UnsupportedVersion),
389 "should name the major version: {message}"
390 );
391 assert!(
392 !message.contains("2.15"),
393 "must not report an LF 1 minor as an LF 2 one: {message}"
394 );
395 }
396
397 #[test]
398 fn the_minor_this_build_targets_decodes() {
399 for minor in SUPPORTED_LF2_MINORS {
400 let bytes = archive_with_minor(minor);
401 assert!(
402 decode_package(&bytes).is_ok(),
403 "minor {minor} should decode"
404 );
405 }
406 }
407
408 /// A newer minor must fail loudly. prost silently drops fields from a
409 /// schema it does not know, so decoding one would hand the user bindings
410 /// that compile and are missing template fields — the failure would surface
411 /// on the wire, far from its cause.
412 #[test]
413 fn an_unknown_minor_is_refused_rather_than_silently_decoded() {
414 for minor in ["3", "17", "dev", ""] {
415 let bytes = archive_with_minor(minor);
416 let err = decode_package(&bytes).expect_err("minor {minor} must be refused");
417 let message = err.to_string();
418 assert!(
419 matches!(err, DecodeError::UnsupportedMinor { .. }),
420 "minor {minor}: {message}"
421 );
422 // The message has to name what was found and what is accepted,
423 // or the reader cannot tell whether to rebuild the DAR or the SDK.
424 assert!(message.contains(&format!("2.{minor}")), "{message}");
425 // *Every* accepted version, spelled in full. Asserting only the
426 // first let "(2.1, 2)" — a `2.` prefix that reached one entry of
427 // the list — pass for as long as the list had one entry.
428 for supported in SUPPORTED_LF2_MINORS {
429 assert!(
430 message.contains(&format!("2.{supported}")),
431 "should name 2.{supported} as supported: {message}"
432 );
433 }
434 }
435 }
436}
437
438#[cfg(test)]
439#[allow(clippy::unwrap_used, clippy::expect_used)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn decodes_a_real_dar_main_package() {
445 let Ok(path) = std::env::var("CANTON_TEST_DAR") else {
446 eprintln!("skipping LF-decode test: set CANTON_TEST_DAR=/path/to/x.dar");
447 return;
448 };
449
450 let dar = Dar::open(&path).expect("open DAR");
451 let (package, package_id) = decode_main_package(&dar).expect("decode LF 2.x package");
452 assert!(
453 !package_id.is_empty(),
454 "package id (archive hash) should be set"
455 );
456
457 assert!(!package.modules.is_empty(), "package should have modules");
458 assert!(
459 !package.interned_strings.is_empty(),
460 "LF 2.x packages intern their strings"
461 );
462 // The SCU inputs — name + version — resolve from the interning table.
463 let name = package_name(&package);
464 let version = package_version(&package);
465 assert!(name.is_some(), "package metadata should carry a name");
466
467 println!(
468 "decoded {} v{} — {} modules, {} interned strings, {} interned types",
469 name.unwrap_or("?"),
470 version.unwrap_or("?"),
471 package.modules.len(),
472 package.interned_strings.len(),
473 package.interned_types.len(),
474 );
475 }
476}