canton-lf 0.3.2

Daml-LF archive (DAR/DALF) reader and decoder for the Canton Rust SDK.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
//! Decode a package's Daml-LF bytes into the LF 2 AST ([`Package`]), and resolve
//! its interned package metadata — the name and version that drive SCU.
//!
//! LF 2.x stores every name and literal in the package's `interned_strings` /
//! `interned_dotted_names` tables and references them by `i32` index, so the raw
//! prost AST is index-heavy; [`interned_str`] resolves those indices.

use prost::Message;

use crate::dar::{Dar, DarError};
use crate::pb::daml_lf::{Archive, ArchivePayload, HashFunction, archive_payload};
use crate::pb::daml_lf_2::Package;
use sha2::{Digest as _, Sha256};

/// An error decoding Daml-LF package bytes.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DecodeError {
    /// Reading the DAR container failed.
    #[error(transparent)]
    Dar(#[from] DarError),
    /// The protobuf bytes were malformed.
    #[error("malformed Daml-LF archive: {0}")]
    Proto(#[from] prost::DecodeError),
    /// The package is not Daml-LF 2.x (this decoder only supports LF 2.x).
    #[error("unsupported Daml-LF version (only LF 2.x is supported)")]
    UnsupportedVersion,
    /// The archive's payload does not hash to the package id it declares.
    /// Refused: that id is the package's identity — it is what generated
    /// bindings embed as `PACKAGE_ID` and what cross-package references resolve
    /// through — so accepting a mismatch means addressing a package by a name
    /// its contents do not answer to.
    #[error("archive payload does not match its declared package id {declared}")]
    PackageIdMismatch {
        /// The id the archive claims.
        declared: String,
        /// The SHA-256 of the payload actually present.
        computed: String,
    },
    /// The archive declares a hash function this build does not implement.
    #[error("unsupported archive hash function {0} (only SHA-256 is defined)")]
    UnsupportedHashFunction(i32),
    /// The package is LF 2.x, but a **minor** this decoder was not built
    /// against. Refused rather than decoded: prost drops fields it does not
    /// know, so a newer minor would otherwise yield silently incomplete
    /// bindings — a template missing a field decodes, compiles, and then fails
    /// on the wire.
    #[error(
        "Daml-LF 2.{minor} is not a version this build was compiled against \
         (it reads {supported}); the LF protos are pinned to a Canton release, \
         so use a DAR built for that release or upgrade the SDK"
    )]
    UnsupportedMinor {
        /// The minor version the archive declares.
        minor: String,
        /// The versions this build accepts, comma-separated and fully spelled
        /// (`"2.1, 2.2"`). Spelled in full rather than as bare minors because
        /// the message renders it verbatim: a `2.` prefix in the template
        /// would attach to the first entry only.
        supported: String,
    },
    /// A decode failure attributed to a specific package in the DAR.
    #[error("package `{name}`: {source}")]
    InPackage {
        /// The archive path of the `.dalf` that failed to decode.
        name: String,
        /// The underlying failure.
        source: Box<DecodeError>,
    },
}

/// Decode the DAR's **main** package into the Daml-LF 2 AST, together with its
/// **package id** (the `Archive` hash — the input to SCU/PackageMap and to
/// qualifying cross-package references).
///
/// # Errors
/// Returns [`DecodeError`] if the container or protobuf bytes are malformed, or
/// the package is not Daml-LF 2.x.
pub fn decode_main_package(dar: &Dar) -> Result<(Package, String), DecodeError> {
    decode_package(dar.main_package_bytes()?)
}

/// Decode a single package's `.dalf` bytes (an `Archive`) into the LF 2 AST and
/// its package id (the archive hash).
///
/// # Errors
/// See [`decode_main_package`].
pub fn decode_package(archive_bytes: &[u8]) -> Result<(Package, String), DecodeError> {
    let archive = Archive::decode(archive_bytes)?;
    let package_id = verified_package_id(&archive)?;
    Ok((decode_payload(&archive.payload)?, package_id))
}

/// Decode an `ArchivePayload` — the *inner* half of an archive, without the
/// hash beside it.
///
/// Which half you have depends on where the package came from. A DAR entry is a
/// whole `Archive`: payload, hash and hash function together, which is why
/// [`decode_package`] can check the id against the bytes. The Ledger API's
/// `GetPackage` hands the three back as separate response fields, so a caller
/// there holds only this — and does the check itself, against the id it asked
/// for. Two shapes, and mistaking one for the other fails with a proto error
/// about a `hash` field that says nothing about the cause.
///
/// # Errors
/// [`DecodeError::Proto`] if the bytes are not an `ArchivePayload`,
/// [`DecodeError::UnsupportedVersion`] if the package is not Daml-LF 2.x, and
/// [`DecodeError::UnsupportedMinor`] for an LF 2 minor this build cannot read.
pub fn decode_payload(payload_bytes: &[u8]) -> Result<Package, DecodeError> {
    let payload = ArchivePayload::decode(payload_bytes)?;
    // Major first: a minor only means something once the major is LF 2. An LF 1
    // archive — which is every DAR a Daml 2.x SDK built — carries minors like
    // "15", and judging it by the minor gate would tell the reader that
    // "Daml-LF 2.15" is unsupported and to upgrade the SDK. No version of this
    // decoder will ever read it; the major is the thing to say.
    // The wrapper keeps the LF 2 package as bytes (the upstream schema avoids a
    // static dependency between the two files), decoded here once the minor
    // is known to be one this build reads.
    let Some(archive_payload::Sum::DamlLf2(package_bytes)) = payload.sum else {
        return Err(DecodeError::UnsupportedVersion);
    };
    check_minor(&payload.minor)?;
    Ok(Package::decode(package_bytes.as_slice())?)
}

/// The package id of an archive, checked against its contents rather than
/// taken on trust.
///
/// The id is the SHA-256 of the payload, and it is load-bearing: generated
/// bindings embed it as `Contract::PACKAGE_ID`, and every cross-package
/// reference in the DAR resolves through it. A DAR whose declared id does not
/// match its bytes — corrupted in transit, or assembled by hand — would
/// otherwise produce bindings that name a package by an id its contents do not
/// answer to, and nothing downstream would notice.
fn verified_package_id(archive: &Archive) -> Result<String, DecodeError> {
    // The enum has exactly one member today; anything else is a scheme this
    // build cannot check, and an unchecked hash is the thing being fixed here.
    if archive.hash_function != HashFunction::Sha256 as i32 {
        return Err(DecodeError::UnsupportedHashFunction(archive.hash_function));
    }

    let computed = hex(&Sha256::digest(&archive.payload));
    if computed == archive.hash {
        Ok(computed)
    } else {
        Err(DecodeError::PackageIdMismatch {
            declared: archive.hash.clone(),
            computed,
        })
    }
}

/// Lower-case hex, the spelling Daml-LF uses for a package id.
fn hex(bytes: &[u8]) -> String {
    bytes
        .iter()
        .fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
            use std::fmt::Write as _;
            // Infallible: writing to a String cannot fail.
            let _ = write!(out, "{byte:02x}");
            out
        })
}

/// The Daml-LF 2.x minor versions this build decodes.
///
/// The list is evidence, not aspiration: a survey of every DAR available here —
/// 18 of them, 648 packages across the Splice amulet/wallet/token-standard and
/// quickstart-licensing sets — finds 617 packages at `2.1` and 31 at `2.2`, and
/// both spellings appear inside DARs this repo generates bindings from; a
/// Canton 3.5.17 DevNet participant additionally vets 21 packages at `2.3`
/// (Digital Asset's utility apps and `daml-stdlib` 3.5.1). All three decode
/// against the vendored schema (Canton 3.5.17's), which the conformance oracle
/// checks against the official JVM `daml-lf-archive` reader.
///
/// Adding a minor is a deliberate act with a procedure: re-vendor the protos if
/// the new minor changes them, then re-run the oracle. Guessing instead means
/// prost silently drops fields belonging to a schema it does not know, and the
/// bindings come out quietly incomplete.
const SUPPORTED_LF2_MINORS: &[&str] = &["1", "2", "3"];

/// Refuse a minor we were not built against.
///
/// `"dev"` is refused with everything else on purpose — it is the unstable
/// spelling, and the whole point of this gate is that an unknown schema must
/// not decode silently.
fn check_minor(minor: &str) -> Result<(), DecodeError> {
    if SUPPORTED_LF2_MINORS.contains(&minor) {
        return Ok(());
    }
    Err(DecodeError::UnsupportedMinor {
        minor: minor.to_string(),
        supported: SUPPORTED_LF2_MINORS
            .iter()
            .map(|minor| format!("2.{minor}"))
            .collect::<Vec<_>>()
            .join(", "),
    })
}

/// Decode **every** package in a DAR, each paired with its package id (archive
/// hash) — the PackageMap for resolving cross-package references.
///
/// # Errors
/// Returns [`DecodeError`] if any package's bytes are malformed or not LF 2.x.
pub fn decode_all(dar: &Dar) -> Result<Vec<(String, Package)>, DecodeError> {
    dar.package_entries()
        .map(|(name, bytes)| {
            let (package, id) = decode_package(bytes).map_err(|error| DecodeError::InPackage {
                name: name.to_string(),
                source: Box::new(error),
            })?;
            Ok((id, package))
        })
        .collect()
}

/// Resolve a `package_import_id` — an index into the package's explicit import
/// table (`package_imports.imported_packages`) — to the target package's id
/// hash. Newer LF (2.dev / SDK 3.5+) references imported packages this way
/// instead of by an interned id string.
#[must_use]
pub fn imported_package_id(package: &Package, index: i32) -> Option<&str> {
    let crate::pb::daml_lf_2::package::ImportsSum::PackageImports(imports) =
        package.imports_sum.as_ref()?
    else {
        return None;
    };
    let i = usize::try_from(index).ok()?;
    imports.imported_packages.get(i).map(String::as_str)
}

/// Resolve an interned string by its `i32` index into a package's
/// `interned_strings` table.
#[must_use]
pub fn interned_str(package: &Package, index: i32) -> Option<&str> {
    usize::try_from(index)
        .ok()
        .and_then(|i| package.interned_strings.get(i))
        .map(String::as_str)
}

/// Resolve an interned dotted name (a module or type name) to its `.`-joined
/// form, e.g. `"Licensing.AppInstall"`.
#[must_use]
pub fn interned_dotted_name(package: &Package, index: i32) -> Option<String> {
    let dotted = usize::try_from(index)
        .ok()
        .and_then(|i| package.interned_dotted_names.get(i))?;
    let segments = dotted
        .segments_interned_str
        .iter()
        .map(|&segment| interned_str(package, segment))
        .collect::<Option<Vec<_>>>()?;
    Some(segments.join("."))
}

/// Resolve an interned type by its `i32` index into a package's `interned_types`
/// table (LF 2.x stores types once and references them by index).
#[must_use]
pub fn interned_type(package: &Package, index: i32) -> Option<&crate::pb::daml_lf_2::Type> {
    usize::try_from(index)
        .ok()
        .and_then(|i| package.interned_types.get(i))
}

/// The package name from its metadata (`PackageMetadata.name_interned_str`).
#[must_use]
pub fn package_name(package: &Package) -> Option<&str> {
    let index = package.metadata.as_ref()?.name_interned_str;
    interned_str(package, index)
}

/// The package version from its metadata (`PackageMetadata.version_interned_str`).
#[must_use]
pub fn package_version(package: &Package) -> Option<&str> {
    let index = package.metadata.as_ref()?.version_interned_str;
    interned_str(package, index)
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod hash_tests {
    use super::*;

    /// An archive whose declared id is the real SHA-256 of its payload.
    fn honest_archive() -> Archive {
        let payload = ArchivePayload {
            minor: "1".to_string(),
            patch: 0,
            sum: Some(archive_payload::Sum::DamlLf2(
                Package::default().encode_to_vec(),
            )),
        }
        .encode_to_vec();
        let hash = hex(&Sha256::digest(&payload));
        Archive {
            hash_function: HashFunction::Sha256 as i32,
            payload,
            hash,
        }
    }

    #[test]
    fn an_archive_that_hashes_to_its_id_decodes_and_returns_that_id() {
        let archive = honest_archive();
        let expected = archive.hash.clone();
        let (_package, id) = decode_package(&archive.encode_to_vec()).expect("honest archive");
        assert_eq!(id, expected);
        assert_eq!(id.len(), 64, "a SHA-256 in hex");
    }

    /// The id is the package's identity: generated bindings embed it as
    /// `PACKAGE_ID` and cross-package references resolve through it. Taking a
    /// mismatched one on trust means addressing a package by a name its
    /// contents do not answer to.
    #[test]
    fn a_payload_that_does_not_match_its_declared_id_is_refused() {
        let mut archive = honest_archive();
        archive.hash = "0".repeat(64);
        let err = decode_package(&archive.encode_to_vec()).expect_err("must not decode");
        assert!(
            matches!(err, DecodeError::PackageIdMismatch { .. }),
            "{err}"
        );
        // Both sides belong in the message: without them a reader cannot tell a
        // corrupted download from a hand-assembled DAR.
        let message = err.to_string();
        assert!(message.contains(&"0".repeat(64)), "{message}");

        // The same when the payload is what changed, which is the realistic
        // shape of the failure.
        let mut tampered = honest_archive();
        tampered.payload.extend_from_slice(b"extra");
        assert!(matches!(
            decode_package(&tampered.encode_to_vec()),
            Err(DecodeError::PackageIdMismatch { .. })
        ));
    }

    #[test]
    fn an_unknown_hash_function_is_refused_rather_than_assumed_to_be_sha256() {
        let mut archive = honest_archive();
        archive.hash_function = 7;
        let err = decode_package(&archive.encode_to_vec()).expect_err("must not decode");
        assert!(
            matches!(err, DecodeError::UnsupportedHashFunction(7)),
            "{err}"
        );
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod minor_tests {
    use super::*;

    /// Build a **well-formed** `Archive` declaring `minor`: the id is the real
    /// SHA-256 of the payload, so these tests exercise the minor gate rather
    /// than tripping the hash gate that runs before it.
    fn archive_with_minor(minor: &str) -> Vec<u8> {
        let payload = ArchivePayload {
            minor: minor.to_string(),
            patch: 0,
            sum: Some(archive_payload::Sum::DamlLf2(
                Package::default().encode_to_vec(),
            )),
        }
        .encode_to_vec();
        let hash = hex(&Sha256::digest(&payload));
        Archive {
            hash_function: HashFunction::Sha256 as i32,
            payload,
            hash,
        }
        .encode_to_vec()
    }

    /// An LF **1.x** archive is the likeliest wrong input there is — every DAR
    /// built by a Daml 2.x SDK is one — so it has to be told the truth: this
    /// decoder handles LF 2 only. Diagnosing it by minor instead would print
    /// "unsupported Daml-LF minor 2.15 … upgrade the SDK", sending the reader
    /// to rebuild a DAR whose major version will never be right.
    #[test]
    fn an_lf1_archive_is_diagnosed_by_its_major_not_its_minor() {
        let payload = ArchivePayload {
            // LF 1.15 was the last LF 1 minor, so this is a real DAR's shape.
            minor: "15".to_string(),
            patch: 0,
            sum: Some(archive_payload::Sum::DamlLf1(vec![1, 2, 3])),
        }
        .encode_to_vec();
        let hash = hex(&Sha256::digest(&payload));
        let bytes = Archive {
            hash_function: HashFunction::Sha256 as i32,
            payload,
            hash,
        }
        .encode_to_vec();

        let err = decode_package(&bytes).expect_err("LF 1 is not decodable here");
        let message = err.to_string();
        assert!(
            matches!(err, DecodeError::UnsupportedVersion),
            "should name the major version: {message}"
        );
        assert!(
            !message.contains("2.15"),
            "must not report an LF 1 minor as an LF 2 one: {message}"
        );
    }

    #[test]
    fn the_minor_this_build_targets_decodes() {
        for minor in SUPPORTED_LF2_MINORS {
            let bytes = archive_with_minor(minor);
            assert!(
                decode_package(&bytes).is_ok(),
                "minor {minor} should decode"
            );
        }
    }

    /// A newer minor must fail loudly. prost silently drops fields from a
    /// schema it does not know, so decoding one would hand the user bindings
    /// that compile and are missing template fields — the failure would surface
    /// on the wire, far from its cause.
    #[test]
    fn an_unknown_minor_is_refused_rather_than_silently_decoded() {
        for minor in ["4", "17", "dev", ""] {
            let bytes = archive_with_minor(minor);
            let err = decode_package(&bytes).expect_err("minor {minor} must be refused");
            let message = err.to_string();
            assert!(
                matches!(err, DecodeError::UnsupportedMinor { .. }),
                "minor {minor}: {message}"
            );
            // The message has to name what was found and what is accepted,
            // or the reader cannot tell whether to rebuild the DAR or the SDK.
            assert!(message.contains(&format!("2.{minor}")), "{message}");
            // *Every* accepted version, spelled in full. Asserting only the
            // first let "(2.1, 2)" — a `2.` prefix that reached one entry of
            // the list — pass for as long as the list had one entry.
            for supported in SUPPORTED_LF2_MINORS {
                assert!(
                    message.contains(&format!("2.{supported}")),
                    "should name 2.{supported} as supported: {message}"
                );
            }
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    #[test]
    fn decodes_a_real_dar_main_package() {
        let Ok(path) = std::env::var("CANTON_TEST_DAR") else {
            eprintln!("skipping LF-decode test: set CANTON_TEST_DAR=/path/to/x.dar");
            return;
        };

        let dar = Dar::open(&path).expect("open DAR");
        let (package, package_id) = decode_main_package(&dar).expect("decode LF 2.x package");
        assert!(
            !package_id.is_empty(),
            "package id (archive hash) should be set"
        );

        assert!(!package.modules.is_empty(), "package should have modules");
        assert!(
            !package.interned_strings.is_empty(),
            "LF 2.x packages intern their strings"
        );
        // The SCU inputs — name + version — resolve from the interning table.
        let name = package_name(&package);
        let version = package_version(&package);
        assert!(name.is_some(), "package metadata should carry a name");

        println!(
            "decoded {} v{} — {} modules, {} interned strings, {} interned types",
            name.unwrap_or("?"),
            version.unwrap_or("?"),
            package.modules.len(),
            package.interned_strings.len(),
            package.interned_types.len(),
        );
    }
}