Skip to main content

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::{Archive, ArchivePayload, HashFunction, archive_payload};
12use crate::pb::daml_lf_2::Package;
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    // The wrapper keeps the LF 2 package as bytes (the upstream schema avoids a
117    // static dependency between the two files), decoded here once the minor
118    // is known to be one this build reads.
119    let Some(archive_payload::Sum::DamlLf2(package_bytes)) = payload.sum else {
120        return Err(DecodeError::UnsupportedVersion);
121    };
122    check_minor(&payload.minor)?;
123    Ok(Package::decode(package_bytes.as_slice())?)
124}
125
126/// The package id of an archive, checked against its contents rather than
127/// taken on trust.
128///
129/// The id is the SHA-256 of the payload, and it is load-bearing: generated
130/// bindings embed it as `Contract::PACKAGE_ID`, and every cross-package
131/// reference in the DAR resolves through it. A DAR whose declared id does not
132/// match its bytes — corrupted in transit, or assembled by hand — would
133/// otherwise produce bindings that name a package by an id its contents do not
134/// answer to, and nothing downstream would notice.
135fn verified_package_id(archive: &Archive) -> Result<String, DecodeError> {
136    // The enum has exactly one member today; anything else is a scheme this
137    // build cannot check, and an unchecked hash is the thing being fixed here.
138    if archive.hash_function != HashFunction::Sha256 as i32 {
139        return Err(DecodeError::UnsupportedHashFunction(archive.hash_function));
140    }
141
142    let computed = hex(&Sha256::digest(&archive.payload));
143    if computed == archive.hash {
144        Ok(computed)
145    } else {
146        Err(DecodeError::PackageIdMismatch {
147            declared: archive.hash.clone(),
148            computed,
149        })
150    }
151}
152
153/// Lower-case hex, the spelling Daml-LF uses for a package id.
154fn hex(bytes: &[u8]) -> String {
155    bytes
156        .iter()
157        .fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
158            use std::fmt::Write as _;
159            // Infallible: writing to a String cannot fail.
160            let _ = write!(out, "{byte:02x}");
161            out
162        })
163}
164
165/// The Daml-LF 2.x minor versions this build decodes.
166///
167/// The list is evidence, not aspiration: a survey of every DAR available here —
168/// 18 of them, 648 packages across the Splice amulet/wallet/token-standard and
169/// quickstart-licensing sets — finds 617 packages at `2.1` and 31 at `2.2`, and
170/// both spellings appear inside DARs this repo generates bindings from; a
171/// Canton 3.5.17 DevNet participant additionally vets 21 packages at `2.3`
172/// (Digital Asset's utility apps and `daml-stdlib` 3.5.1). All three decode
173/// against the vendored schema (Canton 3.5.17's), which the conformance oracle
174/// checks against the official JVM `daml-lf-archive` reader.
175///
176/// Adding a minor is a deliberate act with a procedure: re-vendor the protos if
177/// the new minor changes them, then re-run the oracle. Guessing instead means
178/// prost silently drops fields belonging to a schema it does not know, and the
179/// bindings come out quietly incomplete.
180const SUPPORTED_LF2_MINORS: &[&str] = &["1", "2", "3"];
181
182/// Refuse a minor we were not built against.
183///
184/// `"dev"` is refused with everything else on purpose — it is the unstable
185/// spelling, and the whole point of this gate is that an unknown schema must
186/// not decode silently.
187fn check_minor(minor: &str) -> Result<(), DecodeError> {
188    if SUPPORTED_LF2_MINORS.contains(&minor) {
189        return Ok(());
190    }
191    Err(DecodeError::UnsupportedMinor {
192        minor: minor.to_string(),
193        supported: SUPPORTED_LF2_MINORS
194            .iter()
195            .map(|minor| format!("2.{minor}"))
196            .collect::<Vec<_>>()
197            .join(", "),
198    })
199}
200
201/// Decode **every** package in a DAR, each paired with its package id (archive
202/// hash) — the PackageMap for resolving cross-package references.
203///
204/// # Errors
205/// Returns [`DecodeError`] if any package's bytes are malformed or not LF 2.x.
206pub fn decode_all(dar: &Dar) -> Result<Vec<(String, Package)>, DecodeError> {
207    dar.package_entries()
208        .map(|(name, bytes)| {
209            let (package, id) = decode_package(bytes).map_err(|error| DecodeError::InPackage {
210                name: name.to_string(),
211                source: Box::new(error),
212            })?;
213            Ok((id, package))
214        })
215        .collect()
216}
217
218/// Resolve a `package_import_id` — an index into the package's explicit import
219/// table (`package_imports.imported_packages`) — to the target package's id
220/// hash. Newer LF (2.dev / SDK 3.5+) references imported packages this way
221/// instead of by an interned id string.
222#[must_use]
223pub fn imported_package_id(package: &Package, index: i32) -> Option<&str> {
224    let crate::pb::daml_lf_2::package::ImportsSum::PackageImports(imports) =
225        package.imports_sum.as_ref()?
226    else {
227        return None;
228    };
229    let i = usize::try_from(index).ok()?;
230    imports.imported_packages.get(i).map(String::as_str)
231}
232
233/// Resolve an interned string by its `i32` index into a package's
234/// `interned_strings` table.
235#[must_use]
236pub fn interned_str(package: &Package, index: i32) -> Option<&str> {
237    usize::try_from(index)
238        .ok()
239        .and_then(|i| package.interned_strings.get(i))
240        .map(String::as_str)
241}
242
243/// Resolve an interned dotted name (a module or type name) to its `.`-joined
244/// form, e.g. `"Licensing.AppInstall"`.
245#[must_use]
246pub fn interned_dotted_name(package: &Package, index: i32) -> Option<String> {
247    let dotted = usize::try_from(index)
248        .ok()
249        .and_then(|i| package.interned_dotted_names.get(i))?;
250    let segments = dotted
251        .segments_interned_str
252        .iter()
253        .map(|&segment| interned_str(package, segment))
254        .collect::<Option<Vec<_>>>()?;
255    Some(segments.join("."))
256}
257
258/// Resolve an interned type by its `i32` index into a package's `interned_types`
259/// table (LF 2.x stores types once and references them by index).
260#[must_use]
261pub fn interned_type(package: &Package, index: i32) -> Option<&crate::pb::daml_lf_2::Type> {
262    usize::try_from(index)
263        .ok()
264        .and_then(|i| package.interned_types.get(i))
265}
266
267/// The package name from its metadata (`PackageMetadata.name_interned_str`).
268#[must_use]
269pub fn package_name(package: &Package) -> Option<&str> {
270    let index = package.metadata.as_ref()?.name_interned_str;
271    interned_str(package, index)
272}
273
274/// The package version from its metadata (`PackageMetadata.version_interned_str`).
275#[must_use]
276pub fn package_version(package: &Package) -> Option<&str> {
277    let index = package.metadata.as_ref()?.version_interned_str;
278    interned_str(package, index)
279}
280
281#[cfg(test)]
282#[allow(clippy::unwrap_used, clippy::expect_used)]
283mod hash_tests {
284    use super::*;
285
286    /// An archive whose declared id is the real SHA-256 of its payload.
287    fn honest_archive() -> Archive {
288        let payload = ArchivePayload {
289            minor: "1".to_string(),
290            patch: 0,
291            sum: Some(archive_payload::Sum::DamlLf2(
292                Package::default().encode_to_vec(),
293            )),
294        }
295        .encode_to_vec();
296        let hash = hex(&Sha256::digest(&payload));
297        Archive {
298            hash_function: HashFunction::Sha256 as i32,
299            payload,
300            hash,
301        }
302    }
303
304    #[test]
305    fn an_archive_that_hashes_to_its_id_decodes_and_returns_that_id() {
306        let archive = honest_archive();
307        let expected = archive.hash.clone();
308        let (_package, id) = decode_package(&archive.encode_to_vec()).expect("honest archive");
309        assert_eq!(id, expected);
310        assert_eq!(id.len(), 64, "a SHA-256 in hex");
311    }
312
313    /// The id is the package's identity: generated bindings embed it as
314    /// `PACKAGE_ID` and cross-package references resolve through it. Taking a
315    /// mismatched one on trust means addressing a package by a name its
316    /// contents do not answer to.
317    #[test]
318    fn a_payload_that_does_not_match_its_declared_id_is_refused() {
319        let mut archive = honest_archive();
320        archive.hash = "0".repeat(64);
321        let err = decode_package(&archive.encode_to_vec()).expect_err("must not decode");
322        assert!(
323            matches!(err, DecodeError::PackageIdMismatch { .. }),
324            "{err}"
325        );
326        // Both sides belong in the message: without them a reader cannot tell a
327        // corrupted download from a hand-assembled DAR.
328        let message = err.to_string();
329        assert!(message.contains(&"0".repeat(64)), "{message}");
330
331        // The same when the payload is what changed, which is the realistic
332        // shape of the failure.
333        let mut tampered = honest_archive();
334        tampered.payload.extend_from_slice(b"extra");
335        assert!(matches!(
336            decode_package(&tampered.encode_to_vec()),
337            Err(DecodeError::PackageIdMismatch { .. })
338        ));
339    }
340
341    #[test]
342    fn an_unknown_hash_function_is_refused_rather_than_assumed_to_be_sha256() {
343        let mut archive = honest_archive();
344        archive.hash_function = 7;
345        let err = decode_package(&archive.encode_to_vec()).expect_err("must not decode");
346        assert!(
347            matches!(err, DecodeError::UnsupportedHashFunction(7)),
348            "{err}"
349        );
350    }
351}
352
353#[cfg(test)]
354#[allow(clippy::unwrap_used, clippy::expect_used)]
355mod minor_tests {
356    use super::*;
357
358    /// Build a **well-formed** `Archive` declaring `minor`: the id is the real
359    /// SHA-256 of the payload, so these tests exercise the minor gate rather
360    /// than tripping the hash gate that runs before it.
361    fn archive_with_minor(minor: &str) -> Vec<u8> {
362        let payload = ArchivePayload {
363            minor: minor.to_string(),
364            patch: 0,
365            sum: Some(archive_payload::Sum::DamlLf2(
366                Package::default().encode_to_vec(),
367            )),
368        }
369        .encode_to_vec();
370        let hash = hex(&Sha256::digest(&payload));
371        Archive {
372            hash_function: HashFunction::Sha256 as i32,
373            payload,
374            hash,
375        }
376        .encode_to_vec()
377    }
378
379    /// An LF **1.x** archive is the likeliest wrong input there is — every DAR
380    /// built by a Daml 2.x SDK is one — so it has to be told the truth: this
381    /// decoder handles LF 2 only. Diagnosing it by minor instead would print
382    /// "unsupported Daml-LF minor 2.15 … upgrade the SDK", sending the reader
383    /// to rebuild a DAR whose major version will never be right.
384    #[test]
385    fn an_lf1_archive_is_diagnosed_by_its_major_not_its_minor() {
386        let payload = ArchivePayload {
387            // LF 1.15 was the last LF 1 minor, so this is a real DAR's shape.
388            minor: "15".to_string(),
389            patch: 0,
390            sum: Some(archive_payload::Sum::DamlLf1(vec![1, 2, 3])),
391        }
392        .encode_to_vec();
393        let hash = hex(&Sha256::digest(&payload));
394        let bytes = Archive {
395            hash_function: HashFunction::Sha256 as i32,
396            payload,
397            hash,
398        }
399        .encode_to_vec();
400
401        let err = decode_package(&bytes).expect_err("LF 1 is not decodable here");
402        let message = err.to_string();
403        assert!(
404            matches!(err, DecodeError::UnsupportedVersion),
405            "should name the major version: {message}"
406        );
407        assert!(
408            !message.contains("2.15"),
409            "must not report an LF 1 minor as an LF 2 one: {message}"
410        );
411    }
412
413    #[test]
414    fn the_minor_this_build_targets_decodes() {
415        for minor in SUPPORTED_LF2_MINORS {
416            let bytes = archive_with_minor(minor);
417            assert!(
418                decode_package(&bytes).is_ok(),
419                "minor {minor} should decode"
420            );
421        }
422    }
423
424    /// A newer minor must fail loudly. prost silently drops fields from a
425    /// schema it does not know, so decoding one would hand the user bindings
426    /// that compile and are missing template fields — the failure would surface
427    /// on the wire, far from its cause.
428    #[test]
429    fn an_unknown_minor_is_refused_rather_than_silently_decoded() {
430        for minor in ["4", "17", "dev", ""] {
431            let bytes = archive_with_minor(minor);
432            let err = decode_package(&bytes).expect_err("minor {minor} must be refused");
433            let message = err.to_string();
434            assert!(
435                matches!(err, DecodeError::UnsupportedMinor { .. }),
436                "minor {minor}: {message}"
437            );
438            // The message has to name what was found and what is accepted,
439            // or the reader cannot tell whether to rebuild the DAR or the SDK.
440            assert!(message.contains(&format!("2.{minor}")), "{message}");
441            // *Every* accepted version, spelled in full. Asserting only the
442            // first let "(2.1, 2)" — a `2.` prefix that reached one entry of
443            // the list — pass for as long as the list had one entry.
444            for supported in SUPPORTED_LF2_MINORS {
445                assert!(
446                    message.contains(&format!("2.{supported}")),
447                    "should name 2.{supported} as supported: {message}"
448                );
449            }
450        }
451    }
452}
453
454#[cfg(test)]
455#[allow(clippy::unwrap_used, clippy::expect_used)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn decodes_a_real_dar_main_package() {
461        let Ok(path) = std::env::var("CANTON_TEST_DAR") else {
462            eprintln!("skipping LF-decode test: set CANTON_TEST_DAR=/path/to/x.dar");
463            return;
464        };
465
466        let dar = Dar::open(&path).expect("open DAR");
467        let (package, package_id) = decode_main_package(&dar).expect("decode LF 2.x package");
468        assert!(
469            !package_id.is_empty(),
470            "package id (archive hash) should be set"
471        );
472
473        assert!(!package.modules.is_empty(), "package should have modules");
474        assert!(
475            !package.interned_strings.is_empty(),
476            "LF 2.x packages intern their strings"
477        );
478        // The SCU inputs — name + version — resolve from the interning table.
479        let name = package_name(&package);
480        let version = package_version(&package);
481        assert!(name.is_some(), "package metadata should carry a name");
482
483        println!(
484            "decoded {} v{} — {} modules, {} interned strings, {} interned types",
485            name.unwrap_or("?"),
486            version.unwrap_or("?"),
487            package.modules.len(),
488            package.interned_strings.len(),
489            package.interned_types.len(),
490        );
491    }
492}