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}