Skip to main content

canton_codegen/
generate.rs

1//! The end-to-end pipeline: a `.dar` in, a self-contained bindings **crate**
2//! out (`Cargo.toml` + `src/lib.rs`).
3//!
4//! This is what the `dpm-codegen-rust` binary runs, and what a build script
5//! should call. For finer control (emit a single module, post-process the IR
6//! first), use [`lower_dar`](crate::lower_dar) and
7//! [`generate_crate`](crate::generate_crate) directly.
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use canton_lf::{Dar, DarError, DecodeError, decode_main_package, package_version};
13
14use crate::{CodegenError, SkippedType, generate_crate};
15
16/// The marker written into a generated `Cargo.toml`, and looked for before
17/// overwriting one: a file without it was not written by this tool.
18const CARGO_TOML_MARKER: &str = "# generated by dpm-codegen-rust — do not edit by hand";
19
20/// The marker in a generated `lib.rs` (the first line of its crate docs), used
21/// to recognize this tool's own output before overwriting. Kept in sync with
22/// `crate_docs` — the test below fails if the emitted header stops carrying it.
23const LIB_RS_MARKER: &str = "Typed Rust bindings generated from a Daml archive";
24
25/// Writing a bindings crate failed.
26#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum GenerateError {
29    /// The `.dar` could not be read.
30    #[error("reading {path}")]
31    ReadDar {
32        /// The DAR that could not be read.
33        path: PathBuf,
34        /// The underlying container error.
35        #[source]
36        source: DarError,
37    },
38    /// The `.dar` could not be decoded.
39    #[error("decoding {path}")]
40    DecodeDar {
41        /// The DAR that could not be decoded.
42        path: PathBuf,
43        /// The underlying decode error.
44        #[source]
45        source: DecodeError,
46    },
47    /// The archive held no Daml-LF packages to generate from.
48    #[error("{path} contains no Daml-LF packages — is it a DAR built with `daml build`?")]
49    EmptyDar {
50        /// The offending archive.
51        path: PathBuf,
52    },
53    /// The requested crate name is not a valid Cargo package name.
54    #[error(
55        "`{name}` is not a valid Cargo package name (ASCII letters, digits, `-`/`_`; \
56         must not start with a digit)"
57    )]
58    InvalidCrateName {
59        /// The rejected name.
60        name: String,
61    },
62    /// The DAR's package version is not usable as a Cargo version.
63    ///
64    /// It is refused rather than sanitised because the version reaches the
65    /// generated `Cargo.toml`, and a DAR is not necessarily something the
66    /// caller wrote.
67    #[error(
68        "the DAR declares the package version `{version}`, which is not a semantic \
69         version Cargo will accept (`MAJOR.MINOR.PATCH`, optionally `-pre` and \
70         `+build`). Rebuild the DAR with a plain version"
71    )]
72    InvalidCrateVersion {
73        /// The rejected version, as the DAR declared it.
74        version: String,
75    },
76    /// The `canton-daml` version requirement is not one Cargo can parse.
77    #[error(
78        "`{requirement}` is not a Cargo version requirement (e.g. `0.2`, `^0.2`, \
79         `>=0.2, <0.4`)"
80    )]
81    InvalidRuntimeRequirement {
82        /// The rejected requirement.
83        requirement: String,
84    },
85    /// An output file exists and was not written by this tool.
86    #[error(
87        "{path} already exists and was not generated by this tool; \
88         choose an empty output directory or force the overwrite"
89    )]
90    WouldClobber {
91        /// The file that would have been overwritten.
92        path: PathBuf,
93    },
94    /// The generated tokens were not valid Rust — a bug in this crate.
95    #[error(transparent)]
96    Codegen(#[from] CodegenError),
97    /// Writing the output crate failed.
98    #[error("writing {path}")]
99    Write {
100        /// The path being written.
101        path: PathBuf,
102        /// The underlying I/O error.
103        #[source]
104        source: std::io::Error,
105    },
106}
107
108/// How the generated crate depends on the `canton-daml` runtime.
109#[derive(Debug, Clone)]
110#[non_exhaustive]
111pub enum Runtime {
112    /// A published version requirement (e.g. `0.2`).
113    Version(String),
114    /// A local path — for a monorepo, or to try bindings before publishing.
115    Path(PathBuf),
116}
117
118/// What to generate and where. Build with [`Options::new`], then the
119/// `with_*` methods.
120///
121/// ```no_run
122/// use canton_codegen::{Options, Runtime};
123///
124/// let options = Options::new("my-app-0.1.0.dar", "my-app-bindings")
125///     .with_crate_name("my-app-bindings")
126///     .with_runtime(Runtime::Version("0.2".to_string()));
127/// ```
128#[derive(Debug, Clone)]
129#[non_exhaustive]
130pub struct Options {
131    dar: PathBuf,
132    out: PathBuf,
133    crate_name: Option<String>,
134    runtime: Runtime,
135    force: bool,
136    external: crate::lower::ExternalPackages,
137}
138
139impl Options {
140    /// Generate bindings for `dar` into the crate directory `out` (created if
141    /// absent). The crate name defaults to one derived from the DAR file name
142    /// and the runtime to the matching published `canton-daml`.
143    pub fn new(dar: impl Into<PathBuf>, out: impl Into<PathBuf>) -> Self {
144        Self {
145            dar: dar.into(),
146            out: out.into(),
147            crate_name: None,
148            runtime: Runtime::Version(DEFAULT_RUNTIME_REQ.to_string()),
149            force: false,
150            external: crate::lower::ExternalPackages::new(),
151        }
152    }
153
154    /// Set the generated crate's package name (default: derived from the DAR
155    /// file name — see [`default_crate_name`]).
156    #[must_use]
157    pub fn with_crate_name(mut self, name: impl Into<String>) -> Self {
158        self.crate_name = Some(name.into());
159        self
160    }
161
162    /// Set how the generated crate depends on the `canton-daml` runtime.
163    #[must_use]
164    pub fn with_runtime(mut self, runtime: Runtime) -> Self {
165        self.runtime = runtime;
166        self
167    }
168
169    /// Reference a package that is **already published as its own crate**
170    /// instead of generating it, keyed by package name (preferred) or id.
171    ///
172    /// A DAR's dependency closure is shared — `splice-api-token-holding-v1`
173    /// sits under amulet, wallet and wallet-payments alike — and generating it
174    /// into each crate gives each its own `Holding`, which Rust treats as
175    /// unrelated types. A `ContractId<Holding>` from one then does not
176    /// typecheck against the other, though both name the same interface in the
177    /// same package.
178    ///
179    /// ```no_run
180    /// # use canton_codegen::Options;
181    /// let options = Options::new("splice-amulet.dar", "amulet-bindings")
182    ///     .with_external_package("splice-api-token-holding-v1", "canton_splice_api_token_holding_v1");
183    /// ```
184    #[must_use]
185    pub fn with_external_package(
186        mut self,
187        package: impl Into<String>,
188        crate_name: impl Into<String>,
189    ) -> Self {
190        self.external = self.external.with(package, crate_name);
191        self
192    }
193
194    /// Overwrite output files even if they were not generated by this tool.
195    #[must_use]
196    pub fn with_force(mut self, force: bool) -> Self {
197        self.force = force;
198        self
199    }
200
201    /// The crate name that will be used: the explicit one, or the default
202    /// derived from the DAR path.
203    #[must_use]
204    pub fn crate_name(&self) -> String {
205        self.crate_name
206            .clone()
207            .unwrap_or_else(|| default_crate_name(&self.dar))
208    }
209}
210
211/// The `canton-daml` version requirement written into a generated crate when
212/// the caller does not choose one. Kept in lockstep with this crate's own
213/// version, since the SDK crates release together.
214const DEFAULT_RUNTIME_REQ: &str = concat!(
215    env!("CARGO_PKG_VERSION_MAJOR"),
216    ".",
217    env!("CARGO_PKG_VERSION_MINOR")
218);
219
220/// A summary of a successful generation.
221#[derive(Debug, Clone)]
222#[non_exhaustive]
223pub struct Stats {
224    /// Packages in the DAR closure that produced modules.
225    pub packages: usize,
226    /// Generated Rust submodules.
227    pub modules: usize,
228    /// Generated named items (data types + templates + interfaces).
229    pub items: usize,
230    /// Size of the generated `lib.rs`, in bytes.
231    pub bytes: usize,
232    /// Declarations that could not be lowered (best-effort; surface these as
233    /// warnings).
234    pub skipped: Vec<SkippedType>,
235}
236
237/// Decode `opts.dar`, lower its whole closure, and write a bindings crate
238/// (`Cargo.toml` + `src/lib.rs`) into the output directory.
239///
240/// Refuses to overwrite an existing `Cargo.toml` / `src/lib.rs` that this tool
241/// did not generate, unless [`Options::with_force`] is set — a wrong output
242/// directory must not clobber a hand-written crate.
243///
244/// # Errors
245/// See [`GenerateError`].
246pub fn generate(opts: &Options) -> Result<Stats, GenerateError> {
247    let crate_name = opts.crate_name();
248    validate_crate_name(&crate_name)?;
249
250    let dar = Dar::open(&opts.dar).map_err(|source| GenerateError::ReadDar {
251        path: opts.dar.clone(),
252        source,
253    })?;
254    let (krate, skipped) =
255        crate::lower::lower_dar_with(&dar, &opts.external).map_err(|source| {
256            GenerateError::DecodeDar {
257                path: opts.dar.clone(),
258                source,
259            }
260        })?;
261    if krate.packages.is_empty() {
262        return Err(GenerateError::EmptyDar {
263            path: opts.dar.clone(),
264        });
265    }
266    let source = generate_crate(&krate)?;
267
268    let modules = krate.packages.iter().map(|p| p.modules.len()).sum();
269    let items = krate
270        .packages
271        .iter()
272        .flat_map(|p| &p.modules)
273        .map(|m| m.module.data_types.len() + m.module.templates.len() + m.module.interfaces.len())
274        .sum();
275
276    let cargo_toml_path = opts.out.join("Cargo.toml");
277    let lib_rs_path = opts.out.join("src").join("lib.rs");
278    if !opts.force {
279        refuse_foreign(&cargo_toml_path, CARGO_TOML_MARKER)?;
280        refuse_foreign(&lib_rs_path, LIB_RS_MARKER)?;
281    }
282
283    let src_dir = opts.out.join("src");
284    fs::create_dir_all(&src_dir).map_err(|source| GenerateError::Write {
285        path: src_dir,
286        source,
287    })?;
288    let version = crate_version(&dar);
289    validate_crate_version(&version)?;
290    if let Runtime::Version(requirement) = &opts.runtime {
291        validate_runtime_requirement(requirement)?;
292    }
293    let manifest = cargo_toml(opts, &crate_name, &version);
294    fs::write(&cargo_toml_path, manifest).map_err(|source| GenerateError::Write {
295        path: cargo_toml_path,
296        source,
297    })?;
298    fs::write(&lib_rs_path, &source).map_err(|source| GenerateError::Write {
299        path: lib_rs_path,
300        source,
301    })?;
302
303    Ok(Stats {
304        packages: krate.packages.len(),
305        modules,
306        items,
307        bytes: source.len(),
308        skipped,
309    })
310}
311
312/// Error if `path` exists but does not carry `marker` in its first lines —
313/// i.e. it is someone's own file, not a previous run's output.
314fn refuse_foreign(path: &Path, marker: &str) -> Result<(), GenerateError> {
315    match fs::read_to_string(path) {
316        Ok(existing) => {
317            let head: String = existing.lines().take(5).collect::<Vec<_>>().join("\n");
318            if head.contains(marker) {
319                Ok(()) // ours from a previous run — safe to regenerate over
320            } else {
321                Err(GenerateError::WouldClobber {
322                    path: path.to_path_buf(),
323                })
324            }
325        }
326        Err(_) => Ok(()), // absent (or unreadable, in which case the write errors)
327    }
328}
329
330/// The generated crate's version: the DAR main package's version (so
331/// `splice-amulet 0.1.14` yields a `0.1.14` crate), or `0.0.0` when the
332/// package carries none.
333fn crate_version(dar: &Dar) -> String {
334    decode_main_package(dar)
335        .ok()
336        .and_then(|(package, _)| package_version(&package).map(str::to_string))
337        .map_or_else(|| "0.0.0".to_string(), |version| pad_to_semver(&version))
338}
339
340/// Complete a short but otherwise numeric version: `2.0` becomes `2.0.0`.
341///
342/// Cargo requires all three components — it rejects `2.0`, `0.1` and `1` — and
343/// Daml does not. Refusing the whole DAR over that would leave the caller
344/// stuck, since the version belongs to somebody else's package and they cannot
345/// change it. Anything that is not simply *short* is left exactly as it is, for
346/// [`validate_crate_version`] to refuse.
347fn pad_to_semver(version: &str) -> String {
348    let parts: Vec<&str> = version.split('.').collect();
349    let numeric = parts.len() < 3
350        && !parts.is_empty()
351        && parts
352            .iter()
353            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
354    if numeric {
355        let mut padded = parts.join(".");
356        for _ in parts.len()..3 {
357            padded.push_str(".0");
358        }
359        padded
360    } else {
361        version.to_string()
362    }
363}
364
365/// The `Cargo.toml` for the generated crate: a publishable manifest depending
366/// on the `canton-daml` runtime.
367fn cargo_toml(opts: &Options, crate_name: &str, version: &str) -> String {
368    let dependency = match &opts.runtime {
369        // Escaped for the same reason the path below is: both are interpolated
370        // into a quoted TOML string, and only one of them used to be.
371        Runtime::Version(requirement) => {
372            format!("canton-daml = \"{}\"", toml_escape(requirement))
373        }
374        Runtime::Path(path) => {
375            // A relative path would silently re-anchor at the *output* crate;
376            // resolve it against the invoking directory instead. Escaped as a
377            // TOML basic string (Windows separators, quotes).
378            let absolute = std::path::absolute(path).unwrap_or_else(|_| path.clone());
379            format!(
380                "canton-daml = {{ path = \"{}\" }}",
381                toml_escape(&absolute.display().to_string())
382            )
383        }
384    };
385    // A referenced crate has to be a dependency, or the paths the emitter wrote
386    // do not resolve. The version is left to the caller to pin: this tool knows
387    // the crate's name, not which release of it matches the DAR.
388    let externals = opts
389        .external
390        .crate_names()
391        .iter()
392        .fold(String::new(), |mut out, name| {
393            use std::fmt::Write as _;
394            let _ = writeln!(out, "{} = \"*\"  # pin this", name.replace('_', "-"));
395            out
396        });
397    format!(
398        "{CARGO_TOML_MARKER}\n\
399         [package]\n\
400         name = \"{crate_name}\"\n\
401         version = \"{version}\"\n\
402         edition = \"2021\"\n\
403         description = \"Typed Rust bindings generated from a Daml archive (DAR).\"\n\
404         # Fill these in before publishing the generated crate.\n\
405         # license = \"Apache-2.0\"\n\
406         # repository = \"\"\n\
407         \n\
408         [dependencies]\n{dependency}\n{externals}"
409    )
410}
411
412/// Escape a string for a double-quoted TOML basic string.
413fn toml_escape(raw: &str) -> String {
414    // A quote is not the only way out of a basic string. A raw newline ends it
415    // too — TOML forbids one inside — so escaping only `"` left an input able
416    // to put its own text on the next line of the manifest. Control characters
417    // are escaped as TOML spells them.
418    raw.chars()
419        .fold(String::with_capacity(raw.len()), |mut out, c| {
420            match c {
421                '\\' => out.push_str("\\\\"),
422                '"' => out.push_str("\\\""),
423                '\n' => out.push_str("\\n"),
424                '\r' => out.push_str("\\r"),
425                '\t' => out.push_str("\\t"),
426                c if c.is_control() => {
427                    use std::fmt::Write as _;
428                    let _ = write!(out, "\\u{:04X}", c as u32);
429                }
430                c => out.push(c),
431            }
432            out
433        })
434}
435
436/// Error unless `name` is a valid Cargo package name: non-empty ASCII
437/// alphanumerics, `-` or `_`, not starting with a digit.
438/// Refuse a package version that cannot go into a `Cargo.toml` verbatim.
439///
440/// The version comes out of the DAR, and a DAR is not necessarily something the
441/// caller wrote — the package id is the hash of whatever payload its author
442/// chose, so every integrity guard upstream still passes one that was authored
443/// rather than built. Interpolated raw, a version of the form
444/// `0.1.0"` + newline + `[dependencies.evil]` + `git = "…` closed the string
445/// and opened a table: an arbitrary dependency in a manifest the caller then
446/// compiles.
447///
448/// The name beside it was already validated and the runtime path beside it
449/// already escaped, for this reason. This is the third field in the same
450/// manifest and the one that was missed.
451///
452/// Parsed as a semantic version rather than screened by charset, because
453/// blocking the injection is only half the job: a charset wide enough to admit
454/// every real version also admits `hello`, `1..2` and `1.0_bad`, and those
455/// write a manifest that generation calls a success and every later `cargo`
456/// command fails to parse. `semver` is the crate Cargo itself uses, so this
457/// accepts exactly what a `package.version` may be.
458fn validate_crate_version(version: &str) -> Result<(), GenerateError> {
459    semver::Version::parse(version)
460        .map(|_| ())
461        .map_err(|_| GenerateError::InvalidCrateVersion {
462            version: version.to_string(),
463        })
464}
465
466/// The `canton-daml` version requirement, checked the same way and for the same
467/// two reasons.
468///
469/// It reaches the manifest through the identical `format!`, so a quote or a
470/// newline in it opens a table exactly as the package version did. And a
471/// requirement that is merely mistyped — `--runtime-version 0..2` — otherwise
472/// produces a crate that generates cleanly and cannot be built.
473///
474/// Unlike a version, a *requirement* may carry an operator (`^0.2`, `>=0.2,
475/// <0.4`), so it parses as `VersionReq`.
476fn validate_runtime_requirement(requirement: &str) -> Result<(), GenerateError> {
477    semver::VersionReq::parse(requirement)
478        .map(|_| ())
479        .map_err(|_| GenerateError::InvalidRuntimeRequirement {
480            requirement: requirement.to_string(),
481        })
482}
483
484fn validate_crate_name(name: &str) -> Result<(), GenerateError> {
485    let valid = !name.is_empty()
486        && !name.starts_with(|c: char| c.is_ascii_digit())
487        && name
488            .chars()
489            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
490    if valid {
491        Ok(())
492    } else {
493        Err(GenerateError::InvalidCrateName {
494            name: name.to_string(),
495        })
496    }
497}
498
499/// Derive a crate name from a DAR path: its file stem with the trailing
500/// version dropped, sanitised to a valid Cargo package name
501/// (`splice-amulet-0.1.14.dar` → `splice-amulet`).
502///
503/// The version is left out on purpose: a DAR version bump should not rename the
504/// crate a caller depends on (the *crate version* carries it instead).
505#[must_use]
506pub fn default_crate_name(dar: &Path) -> String {
507    let stem = dar
508        .file_stem()
509        .and_then(|s| s.to_str())
510        .unwrap_or("bindings");
511    let stem = strip_trailing_version(stem);
512    let name: String = stem
513        .chars()
514        .map(|c| {
515            if c.is_ascii_alphanumeric() || c == '-' {
516                c
517            } else {
518                '-'
519            }
520        })
521        .collect();
522    let name = name.trim_matches('-').to_string();
523    // A Cargo package name cannot be empty or start with a digit.
524    if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {
525        format!("bindings-{name}").trim_end_matches('-').to_string()
526    } else {
527        name
528    }
529}
530
531/// Drop a trailing `-<version>` (dot-separated digits) from a DAR file stem.
532fn strip_trailing_version(stem: &str) -> &str {
533    match stem.rsplit_once('-') {
534        Some((head, tail))
535            if !tail.is_empty()
536                && tail
537                    .split('.')
538                    .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) =>
539        {
540            head
541        }
542        _ => stem,
543    }
544}
545
546#[cfg(test)]
547#[allow(clippy::unwrap_used, clippy::expect_used)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn default_crate_names_drop_the_dar_version_and_stay_valid() {
553        let cases = [
554            // A DAR version bump must not rename the crate.
555            ("splice-amulet-0.1.14.dar", "splice-amulet"),
556            ("splice-amulet-0.1.15.dar", "splice-amulet"),
557            ("quickstart-licensing-0.0.1.dar", "quickstart-licensing"),
558            ("quickstart licensing.dar", "quickstart-licensing"),
559            // Not a version suffix — kept.
560            ("my-app-v2.dar", "my-app-v2"),
561            // Degenerate: a name that is only a version keeps its digits
562            // behind the `bindings-` prefix (a Cargo name cannot lead with one).
563            ("0.1.14.dar", "bindings-0-1-14"),
564        ];
565        for (input, expected) in cases {
566            let name = default_crate_name(Path::new(input));
567            assert_eq!(name, expected, "for {input}");
568            assert!(validate_crate_name(&name).is_ok(), "{name}");
569        }
570    }
571
572    #[test]
573    fn invalid_crate_names_are_rejected_with_a_typed_error() {
574        for bad in ["", "1abc", "has space", "naïve"] {
575            assert!(matches!(
576                validate_crate_name(bad),
577                Err(GenerateError::InvalidCrateName { .. })
578            ));
579        }
580        assert!(validate_crate_name("fine-name_2").is_ok());
581    }
582
583    #[test]
584    fn cargo_toml_is_publishable_and_escapes_windows_paths() {
585        let options = Options::new("x.dar", "out")
586            .with_runtime(Runtime::Path(PathBuf::from(r"C:\Users\dev\canton-daml")));
587        let toml = cargo_toml(&options, "x", "0.0.0");
588        // Backslashes must be escaped or the TOML string is invalid.
589        assert!(toml.contains(r"C:\\Users\\dev\\canton-daml"), "{toml}");
590        assert!(toml.starts_with(CARGO_TOML_MARKER), "{toml}");
591        // A description is present, and no `[workspace]` stanza is forced on
592        // the caller's layout.
593        assert!(toml.contains("description = "), "{toml}");
594        assert!(!toml.contains("[workspace]"), "{toml}");
595    }
596
597    #[test]
598    fn the_default_runtime_requirement_tracks_this_crate() {
599        // Lockstep: a generated crate asks for the runtime that matches the
600        // codegen that wrote it.
601        assert_eq!(
602            DEFAULT_RUNTIME_REQ,
603            concat!(
604                env!("CARGO_PKG_VERSION_MAJOR"),
605                ".",
606                env!("CARGO_PKG_VERSION_MINOR")
607            )
608        );
609        assert!(matches!(
610            Options::new("x.dar", "o").runtime,
611            Runtime::Version(_)
612        ));
613    }
614
615    /// The clobber guard recognises this tool's own output only if the emitted
616    /// header actually contains the marker — so assert against a real emission,
617    /// not against the constant.
618    #[test]
619    fn the_lib_rs_marker_matches_what_the_generator_emits() {
620        let emitted = crate::generate_crate(&crate::ir::Crate::default()).unwrap();
621        assert!(
622            emitted.contains(LIB_RS_MARKER),
623            "generated header must carry the marker, got:\n{emitted}"
624        );
625    }
626
627    #[test]
628    fn refuses_to_overwrite_a_foreign_file() {
629        let dir = std::env::temp_dir().join(format!("codegen-clobber-{}", std::process::id()));
630        std::fs::create_dir_all(&dir).unwrap();
631        let file = dir.join("Cargo.toml");
632
633        std::fs::write(&file, "[package]\nname = \"users-own-crate\"\n").unwrap();
634        assert!(matches!(
635            refuse_foreign(&file, CARGO_TOML_MARKER),
636            Err(GenerateError::WouldClobber { .. })
637        ));
638
639        // Our own previous output is fine to regenerate over, as is an absent file.
640        std::fs::write(&file, format!("{CARGO_TOML_MARKER}\n[package]\n")).unwrap();
641        assert!(refuse_foreign(&file, CARGO_TOML_MARKER).is_ok());
642        assert!(refuse_foreign(&dir.join("absent.toml"), CARGO_TOML_MARKER).is_ok());
643
644        std::fs::remove_dir_all(&dir).ok();
645    }
646    /// The version reaches `Cargo.toml`, and it comes out of the DAR — which
647    /// the caller did not necessarily write. Every integrity guard upstream
648    /// still passes an *authored* archive, because the package id is the hash
649    /// of whatever payload its author chose.
650    ///
651    /// Interpolated raw, this closed the string and opened a table: an
652    /// arbitrary git dependency in a manifest the caller then compiles. The
653    /// name beside it was already validated and the runtime path beside it
654    /// already escaped; this was the third field in the same manifest.
655    #[test]
656    fn a_package_version_cannot_inject_into_the_manifest() {
657        let hostile = "0.1.0\"\n[dependencies.evil]\ngit = \"https://example.invalid/repo";
658        let error = validate_crate_version(hostile).expect_err("must be refused");
659        assert!(
660            matches!(error, GenerateError::InvalidCrateVersion { .. }),
661            "{error}"
662        );
663        // The message shows what was rejected, so the reader can go and look at
664        // the DAR rather than guess.
665        assert!(error.to_string().contains("0.1.0"), "{error}");
666
667        // Neither can a newline on its own, which is all it takes to start a
668        // new key once the quote is out.
669        assert!(validate_crate_version("0.1.0\nx = 1").is_err());
670        assert!(validate_crate_version("").is_err());
671
672        // Cargo itself rejects a two-component version, so this must too —
673        // `2.0` is completed to `2.0.0` upstream, in `crate_version`, not
674        // waved through here.
675        assert!(validate_crate_version("2.0").is_err());
676        assert_eq!(pad_to_semver("2.0"), "2.0.0");
677        assert_eq!(pad_to_semver("1"), "1.0.0");
678        assert_eq!(
679            pad_to_semver("0.1.14"),
680            "0.1.14",
681            "a complete version is untouched"
682        );
683        assert_eq!(
684            pad_to_semver("hello"),
685            "hello",
686            "padding never rescues a non-version"
687        );
688
689        // Everything a real package carries still passes.
690        for good in [
691            "0.0.0",
692            "1.2.3",
693            "0.1.14",
694            "1.0.0-SNAPSHOT",
695            "1.0.0-rc.1+build.5",
696        ] {
697            assert!(
698                validate_crate_version(good).is_ok(),
699                "{good} should be accepted"
700            );
701        }
702    }
703    /// Both fields that reach the manifest as text, checked the way Cargo
704    /// checks them.
705    ///
706    /// A charset was enough to stop the injection and not enough to stop a
707    /// broken manifest: it admitted `hello` and `1..2`, which generate cleanly
708    /// and then fail every later `cargo` command with a parse error nowhere
709    /// near its cause.
710    #[test]
711    fn a_manifest_is_never_written_with_a_version_cargo_cannot_read() {
712        // Rejected: not versions at all.
713        for bad in ["hello", "1..2", "1.0_bad", "", "1.0", "v1.0.0"] {
714            assert!(
715                validate_crate_version(bad).is_err(),
716                "`{bad}` is not a Cargo package version"
717            );
718        }
719        // Accepted: every shape a real DAR carries, plus the semver extras.
720        for good in [
721            "0.0.1",
722            "0.1.14",
723            "1.0.0",
724            "1.0.6",
725            "1.0.0-SNAPSHOT",
726            "1.0.0-rc.1+build.5",
727        ] {
728            assert!(validate_crate_version(good).is_ok(), "{good}");
729        }
730
731        // The requirement is a *requirement*, so operators are legal here and
732        // a bare `0.2` — what this crate emits by default — must pass.
733        for good in [DEFAULT_RUNTIME_REQ, "0.2", "^0.2", ">=0.2, <0.4", "*"] {
734            assert!(validate_runtime_requirement(good).is_ok(), "{good}");
735        }
736        for bad in ["0..2", "hello", "", ">>0.2"] {
737            assert!(validate_runtime_requirement(bad).is_err(), "`{bad}`");
738        }
739    }
740
741    /// The two arms of one `match` disagreed: the path was escaped, the version
742    /// requirement three lines above it was not. Both reach the same quoted
743    /// TOML string.
744    #[test]
745    fn a_runtime_requirement_cannot_inject_into_the_manifest() {
746        let hostile = "0.2\"\n[dependencies.evil]\ngit = \"https://example.invalid/repo";
747        assert!(
748            validate_runtime_requirement(hostile).is_err(),
749            "must be refused before it reaches the manifest"
750        );
751
752        // And if it somehow did, the escape is now there too — no unescaped
753        // quote survives into the file.
754        let opts = Options::new("x.dar", "out").with_runtime(Runtime::Version(hostile.to_string()));
755        let manifest = cargo_toml(&opts, "bindings", "1.0.0");
756        // The text may survive as *content* of the escaped string — harmless.
757        // What must not happen is a line of the manifest starting with it,
758        // which is what a table header is.
759        assert!(
760            !manifest
761                .lines()
762                .any(|l| l.trim_start().starts_with('[') && l.contains("evil")),
763            "escaped output opened a table:\n{manifest}"
764        );
765        // And nothing may break out of the dependency line at all.
766        let dependency = manifest
767            .lines()
768            .find(|l| l.starts_with("canton-daml"))
769            .expect("the dependency line");
770        assert!(!dependency.contains('\n'));
771    }
772}