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