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}