Skip to main content

jam_pvm_builder/
lib.rs

1//! Builder logic for creating PVM code blobs for execution on the JAM PVM instances (service code
2//! and authorizer code).
3//!
4//! # Reproducibility
5//!
6//! Two pieces here drive cross-machine reproducibility of the produced blob:
7//!
8//! - **Path remapping (`build_encoded_rustflags`).** Adds `--remap-path-prefix` directives that
9//!   rewrite `$HOME`, `$RUSTUP_HOME`, `$CARGO_HOME`, and each workspace-member path to stable roots
10//!   (`~/`, `~/.rustup`, `~/.cargo`, `/crate/<name>`). This prevents the user's actual home, cargo
11//!   cache, and workspace location from leaking into `file!()` strings and embedded debuginfo.
12//!   Sufficient to make builds byte-identical across different machines running the same host OS
13//!   (tested on Linux).
14//!
15//! - **`RUSTC_WRAPPER` forwarding (`build_pvm_blob`).** The inner cargo invocation does
16//!   `env_clear()` and then selectively forwards a few env vars; `RUSTC_WRAPPER` is one of them
17//!   when the caller sets it. Used to additionally normalise cross-OS (Linux vs macOS) blob output
18//!   via a custom wrapper that rewrites cargo's per-crate `-Cmetadata` to a host-independent value.
19//!   Wrapper lives at `scripts/rustc-wrapper-fixed-metadata.sh` in this repository; see its header
20//!   for details. Set it like:
21//!
22//!   ```text
23//!   RUSTC_WRAPPER=$(pwd)/scripts/rustc-wrapper-fixed-metadata.sh cargo build -p polkajam-fuzz
24//!   ```
25//!
26//!   Default builds (no `RUSTC_WRAPPER` exported) only get the path-remap form of
27//!   reproducibility — that's the same-OS guarantee.
28
29#![allow(clippy::unwrap_used)]
30
31// If you update this, you should also update the toolchain installed by .github/workflows/rust.yml
32const TOOLCHAIN: &str = "nightly-2025-05-10";
33
34use codec::Encode;
35use jam_program_blob_common::{ConventionalMetadata, CoreVmProgramBlob, CrateInfo, ProgramBlob};
36use std::{
37	fmt::Display,
38	fs,
39	path::{Path, PathBuf},
40	process::Command,
41	sync::OnceLock,
42};
43
44/// Program blob type.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub enum BlobType {
47	/// JAM service (`jam_pvm_common::Service`).
48	Service,
49	/// JAM authorizer (`jam_pvm_common::Authorizer`).
50	Authorizer,
51	/// CoreVM guest program (`corevm_guest` crate).
52	CoreVmGuest,
53}
54
55impl Display for BlobType {
56	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57		std::fmt::Debug::fmt(self, f)
58	}
59}
60
61impl BlobType {
62	pub fn dispatch_table(&self) -> Vec<Vec<u8>> {
63		match self {
64			Self::Service => vec![b"refine_ext".into(), b"accumulate_ext".into()],
65			Self::Authorizer => vec![b"is_authorized_ext".into()],
66			Self::CoreVmGuest => Vec::new(),
67		}
68	}
69
70	/// Get output file path for the specified crate name and output directory.
71	pub fn output_file(&self, out_dir: &Path, crate_name: &str) -> PathBuf {
72		let suffix = match self {
73			Self::Service | Self::Authorizer => "jam",
74			Self::CoreVmGuest => "corevm",
75		};
76		out_dir.join(format!("{crate_name}.{suffix}"))
77	}
78}
79
80pub enum ProfileType {
81	Debug,
82	Release,
83	Other(&'static str),
84}
85impl ProfileType {
86	fn as_str(&self) -> &'static str {
87		match self {
88			ProfileType::Debug => "debug",
89			ProfileType::Release => "release",
90			ProfileType::Other(s) => s,
91		}
92	}
93	fn to_arg(&self) -> String {
94		match self {
95			ProfileType::Debug => "--debug".into(),
96			ProfileType::Release => "--release".into(),
97			ProfileType::Other(s) => format!("--profile={s}"),
98		}
99	}
100
101	/// Whether this profile should optimise for a small, opaque blob.
102	/// Disabled for debug builds.
103	fn is_release_like(&self) -> bool {
104		!matches!(self, ProfileType::Debug)
105	}
106}
107
108fn build_pvm_blob_in_build_script(crate_dir: &Path, blob_type: BlobType) {
109	let out_dir: PathBuf = std::env::var("OUT_DIR").expect("No OUT_DIR").into();
110	println!("cargo:rerun-if-env-changed=SKIP_PVM_BUILDS");
111	println!("cargo:rerun-if-env-changed=PVM_BUILDER_STRIP");
112	if std::env::var_os("SKIP_PVM_BUILDS").is_some() {
113		let crate_name = get_crate_info(crate_dir).name;
114		let output_file = blob_type.output_file(&out_dir, &crate_name);
115		fs::write(&output_file, []).expect("error creating dummy program blob");
116		println!("cargo:rustc-env=PVM_BINARY_{crate_name}={}", output_file.display());
117		let hash_output_file = out_dir.join("{crate_name}.hash");
118		fs::write(&hash_output_file, [0_u8; 32]).expect("error creating dummy program blob hash");
119		println!("cargo:rustc-env=PVM_BINARY_HASH_{crate_name}={}", hash_output_file.display());
120	} else {
121		println!("cargo:rerun-if-changed={}", crate_dir.to_str().unwrap());
122		let (crate_name, output_file, hash_output_file) =
123			build_pvm_blob(crate_dir, blob_type, &out_dir, false, ProfileType::Other("production"));
124		println!("cargo:rustc-env=PVM_BINARY_{crate_name}={}", output_file.display());
125		println!("cargo:rustc-env=PVM_BINARY_HASH_{crate_name}={}", hash_output_file.display());
126	}
127}
128
129/// Build the service crate in `crate_dir` for PVM.
130///
131/// Outputs
132/// - `{out_dir}/{crate_name}.jam` - JAM program blob,
133/// - `{out_dir}/{crate_name}.polkavm` - PolkaVM program blob for debugging.
134///
135/// The blob may be included in the relevant crate by using the [`pvm_binary`] macro.
136pub fn build_service(crate_dir: &Path) {
137	build_pvm_blob_in_build_script(crate_dir, BlobType::Service);
138}
139
140/// Build the authorizer crate in `crate_dir` for PVM.
141///
142/// Outputs
143/// - `{out_dir}/{crate_name}.jam` - JAM program blob,
144/// - `{out_dir}/{crate_name}.polkavm` - PolkaVM program blob for debugging.
145///
146/// The blob may be included in the relevant crate by using the [`pvm_binary`] macro.
147pub fn build_authorizer(crate_dir: &Path) {
148	build_pvm_blob_in_build_script(crate_dir, BlobType::Authorizer);
149}
150
151/// Build the CoreVM guest program crate in `crate_dir` for PVM.
152///
153/// Outputs
154/// - `{out_dir}/{crate_name}.corevm` - CoreVM program blob,
155/// - `{out_dir}/{crate_name}.polkavm` - PolkaVM program blob for debugging.
156///
157/// The blob may be included in the relevant crate by using the [`pvm_binary`] macro.
158pub fn build_corevm_guest(crate_dir: &Path) {
159	build_pvm_blob_in_build_script(crate_dir, BlobType::CoreVmGuest);
160}
161
162/// Build the `CARGO_ENCODED_RUSTFLAGS` value for the PVM build.
163///
164/// `--remap-path-prefix` directives prevent absolute paths (rust-src, cargo registry, workspace)
165/// from leaking into `file!()` strings embedded in the PVM blob. Without them the blob - and
166/// therefore its hash - is not reproducible across machines or toolchain installs.
167///
168/// Remap targets are chosen so PolkaVM's `source_cache` can resolve embedded paths back to local
169/// files on whatever machine later inspects the blob: it strips a leading `~/` and joins with the
170/// running process's own `$HOME`, so an embedded `~/.rustup/toolchains/<channel>/...` finds the
171/// equivalent file under any user who has that toolchain installed via rustup. Workspace members
172/// each get a stable `/crate/<name>` root.
173///
174/// Note: in release-like builds `panic_immediate_abort` (set in `build-std-features` below)
175/// lowers panics to `abort()` without consuming the `&Location` propagated by `#[track_caller]`,
176/// so the file/line/column literals are DCE'd. In debug builds those literals reach rodata, but
177/// always in remapped form, which keeps cross-host reproducibility.
178fn build_encoded_rustflags(crate_dir: &Path) -> String {
179	let mut flags: Vec<String> = vec!["-C".into(), "panic=abort".into()];
180
181	// Order matters: rustc applies the LAST matching `--remap-path-prefix`, so the broadest
182	// catch-all goes first and the most specific override goes last.
183	let home = std::env::var("HOME").ok();
184	if let Some(h) = home.as_deref() {
185		flags.push(format!("--remap-path-prefix={h}=~"));
186	}
187	let rustup = std::env::var("RUSTUP_HOME")
188		.ok()
189		.or_else(|| home.as_deref().map(|h| format!("{h}/.rustup")));
190	if let Some(p) = rustup {
191		flags.push(format!("--remap-path-prefix={p}=~/.rustup"));
192	}
193	let cargo = std::env::var("CARGO_HOME")
194		.ok()
195		.or_else(|| home.as_deref().map(|h| format!("{h}/.cargo")));
196	if let Some(p) = cargo {
197		flags.push(format!("--remap-path-prefix={p}=~/.cargo"));
198	}
199	for (name, path) in workspace_members(crate_dir) {
200		flags.push(format!("--remap-path-prefix={}=/crate/{name}", path.display()));
201	}
202
203	flags.join("\x1f")
204}
205
206/// Enumerate workspace members visible from `crate_dir` as `(name, manifest_dir)` pairs.
207fn workspace_members(crate_dir: &Path) -> impl Iterator<Item = (String, PathBuf)> {
208	let packages = (|| -> Option<Vec<serde_json::Value>> {
209		let output = Command::new("cargo")
210			.current_dir(crate_dir)
211			.args(["metadata", "--no-deps", "--format-version", "1"])
212			.output()
213			.ok()
214			.filter(|o| o.status.success())?;
215		let mut meta: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
216		match meta.get_mut("packages")?.take() {
217			serde_json::Value::Array(arr) => Some(arr),
218			_ => None,
219		}
220	})()
221	.unwrap_or_default();
222
223	packages.into_iter().filter_map(|pkg| {
224		let name = pkg.get("name")?.as_str()?.to_string();
225		let manifest = pkg.get("manifest_path")?.as_str()?;
226		let path = Path::new(manifest).parent()?.to_path_buf();
227		Some((name, path))
228	})
229}
230
231fn get_crate_info(crate_dir: &Path) -> CrateInfo {
232	let read_manifest_output = Command::new("cargo")
233		.current_dir(crate_dir)
234		.arg("read-manifest")
235		.output()
236		.unwrap_or_else(|err| {
237			panic!("Failed to run `cargo read-manifest` in {}: {err}", crate_dir.display());
238		});
239	if !read_manifest_output.status.success() {
240		panic!(
241			"Failed to read Cargo.toml manifest in {}:\n{}",
242			crate_dir.display(),
243			String::from_utf8_lossy(&read_manifest_output.stderr)
244		);
245	}
246	let man = serde_json::from_slice::<serde_json::Value>(&read_manifest_output.stdout).unwrap();
247	// read-manifest output should always contain a valid name/version
248	let name = man.get("name").unwrap().as_str().unwrap().to_string();
249	let version = man.get("version").unwrap().as_str().unwrap().to_string();
250	// read-manifest output contains "license": null when no license is specified in the Cargo.toml
251	let license = man
252		.get("license")
253		.unwrap()
254		.as_str()
255		.unwrap_or_else(|| {
256			panic!("No license specified in Cargo.toml manifest in {}", crate_dir.display());
257		})
258		.to_string();
259	// read-manifest output should always contain a valid authors list
260	let authors = man
261		.get("authors")
262		.unwrap()
263		.as_array()
264		.unwrap()
265		.iter()
266		.map(|x| x.as_str().unwrap().to_owned())
267		.collect::<Vec<String>>();
268	CrateInfo { name, version, license, authors }
269}
270
271/// Build the PVM crate in `crate_dir` for the RISCV target.
272///
273/// Outputs
274/// - depending on the `blob_type` either [JAM program blob](jam_program_blob_common::ProgramBlob)
275///   or [CoreVM program blob](jam_program_blob_common::CoreVmProgramBlob) as
276///   `{out_dir}/{crate_name}.jam` or `{out_dir}/{crate_name}.corevm` respectively;
277/// - [PolkaVM program blob](polkavm_linker::ProgramBlob) as `{out_dir}/{crate_name}.polkavm` for
278///   debugging.
279/// - Blake2b hash of the PolkaVM program blob as `{out_dir}/{crate_name}.hash`.
280///
281/// `out_dir` is used to store any intermediate build files.
282pub fn build_pvm_blob(
283	crate_dir: &Path,
284	blob_type: BlobType,
285	out_dir: &Path,
286	install_rustc: bool,
287	profile: ProfileType,
288) -> (String, PathBuf, PathBuf) {
289	let mut args = polkavm_linker::TargetJsonArgs::default();
290	args.is_64_bit = true;
291	args.rustc_version = polkavm_linker::RustcVersion::Legacy;
292
293	let (target_name, target_json_path) =
294		("riscv64emac-unknown-none-polkavm", polkavm_linker::target_json_path(args).unwrap());
295
296	println!("🪤 PVM module type: {blob_type}");
297	println!("🎯 Target name: {target_name}");
298
299	let rustup_installed = if Command::new("rustup").output().is_ok() {
300		let output = Command::new("rustup")
301			.args(["component", "list", "--toolchain", TOOLCHAIN, "--installed"])
302			.output()
303			.unwrap_or_else(|_| {
304				panic!(
305				"Failed to execute `rustup component list --toolchain {TOOLCHAIN} --installed`.\n\
306		Please install `rustup` to continue.",
307			)
308			});
309
310		if !output.status.success() ||
311			!output.stdout.split(|x| *x == b'\n').any(|x| x[..] == b"rust-src"[..])
312		{
313			if install_rustc {
314				println!("Installing rustc dependencies...");
315				let mut child = Command::new("rustup")
316					.args(["toolchain", "install", TOOLCHAIN, "-c", "rust-src"])
317					.stdout(std::process::Stdio::inherit())
318					.stderr(std::process::Stdio::inherit())
319					.spawn()
320					.unwrap_or_else(|_| {
321						panic!(
322						"Failed to execute `rustup toolchain install {TOOLCHAIN} -c rust-src`.\n\
323				Please install `rustup` to continue."
324					)
325					});
326				if !child.wait().expect("Failed to execute rustup process").success() {
327					panic!("Failed to install `rust-src` component of {TOOLCHAIN}.");
328				}
329			} else {
330				panic!("`rust-src` component of {TOOLCHAIN} is required to build the PVM binary.",);
331			}
332		}
333		println!("ℹ️ `rustup` and toolchain installed. Continuing build process...");
334
335		true
336	} else {
337		println!("ℹ️ `rustup` not installed, here be dragons. Continuing build process...");
338
339		false
340	};
341
342	let info = get_crate_info(crate_dir);
343	println!("📦 Crate name: {}", info.name);
344	println!("🏷️ Build profile: {}", profile.as_str());
345
346	let mut child = Command::new("cargo");
347
348	child
349		.current_dir(crate_dir)
350		.env_clear()
351		.env("PATH", std::env::var("PATH").unwrap())
352		.env("CARGO_ENCODED_RUSTFLAGS", build_encoded_rustflags(crate_dir))
353		.env("CARGO_TARGET_DIR", out_dir)
354		// Support building on stable. (required for `-Zbuild-std`)
355		.env("RUSTC_BOOTSTRAP", "1");
356
357	// Forward `RUSTC_WRAPPER` if set in the parent env. Used by callers that need
358	// cross-host-reproducible PVM blobs
359	// (e.g. `RUSTC_WRAPPER=scripts/rustc-wrapper-fixed-metadata.sh cargo build -p polkajam-fuzz`)
360	// the wrapper can rewrite `-Cmetadata` so rustc's `StableCrateId` is identical on Linux and
361	// macOS. Default builds inherit no wrapper.
362	if let Some(w) = std::env::var_os("RUSTC_WRAPPER") {
363		child.env("RUSTC_WRAPPER", w);
364	}
365
366	if rustup_installed {
367		child.arg(format!("+{TOOLCHAIN}"));
368	}
369
370	child.args(["rustc", "--lib", "--crate-type=cdylib", "-Z", "build-std=core,alloc"]);
371	if profile.is_release_like() {
372		// Lowers every panic site to a direct `intrinsics::abort()`,
373		// so panic messages and `track_caller` `&Location` data get DCE'd out of rodata.
374		child.args(["-Z", "build-std-features=panic_immediate_abort"]);
375	}
376	child.arg(profile.to_arg()).arg("--target").arg(target_json_path);
377
378	// Use job server to not oversubscribe CPU cores when compiling multiple PVM binaries in
379	// parallel.
380	if let Some(client) = get_job_server_client() {
381		client.configure(&mut child);
382	}
383
384	let mut child = child.spawn().expect("Failed to execute cargo process");
385	let status = child.wait().expect("Failed to execute cargo process");
386
387	if !status.success() {
388		eprintln!("Failed to build RISC-V ELF due to cargo execution error");
389		std::process::exit(1);
390	}
391
392	// Post processing
393	println!("Converting RISC-V ELF to PVM blob...");
394	let mut config = polkavm_linker::Config::default();
395	config.set_strip(std::env::var("PVM_BUILDER_STRIP").map(|value| value == "1").unwrap_or(true));
396	config.set_dispatch_table(blob_type.dispatch_table());
397
398	let input_root = &out_dir.join(target_name).join(profile.as_str());
399	let input_path_bin = input_root.join(&info.name);
400	let input_path_cdylib = input_root.join(format!("{}.elf", info.name.replace("-", "_")));
401
402	let input_path = if input_path_cdylib.exists() {
403		if input_path_bin.exists() {
404			eprintln!(
405				"Both {} and {} exist; run 'cargo clean' to get rid of old artifacts!",
406				input_path_cdylib.display(),
407				input_path_bin.display()
408			);
409			std::process::exit(1);
410		}
411		input_path_cdylib
412	} else if input_path_bin.exists() {
413		input_path_bin
414	} else {
415		eprintln!(
416			"Failed to build: neither {} nor {} exist",
417			input_path_cdylib.display(),
418			input_path_bin.display()
419		);
420		std::process::exit(1);
421	};
422
423	let orig =
424		fs::read(&input_path).unwrap_or_else(|e| panic!("Failed to read {input_path:?} :{e:?}"));
425	let linked = polkavm_linker::program_from_elf(
426		config,
427		polkavm_linker::TargetInstructionSet::JamV1,
428		orig.as_ref(),
429	)
430	.expect("Failed to link pvm program:");
431
432	// Write out a full `.polkavm` blob for debugging/inspection.
433	let output_path_pvm = out_dir.join(format!("{}.polkavm", info.name));
434	let hash_output_file = out_dir.join(format!("{}.hash", info.name));
435	fs::write(&output_path_pvm, &linked).expect("Error writing resulting binary");
436	let name = info.name.clone();
437	let metadata = ConventionalMetadata::Info(info).encode().into();
438	let output_file = blob_type.output_file(out_dir, &name);
439	let blob = if !matches!(blob_type, BlobType::CoreVmGuest) {
440		let parts = polkavm_linker::ProgramParts::from_bytes(linked.into())
441			.expect("failed to deserialize linked PolkaVM program");
442		let blob = ProgramBlob::from_pvm(&parts, metadata)
443			.to_vec()
444			.expect("error serializing the .jam blob");
445		fs::write(&output_file, &blob).expect("error writing the .jam blob");
446		blob
447	} else {
448		let blob = CoreVmProgramBlob { metadata, pvm_blob: linked.into() }
449			.to_vec()
450			.expect("error serializing the CoreVM blob");
451		fs::write(&output_file, &blob).expect("error writing the CoreVM blob");
452		blob
453	};
454	let hash = code_hash(&blob);
455	fs::write(&hash_output_file, hash).expect("error writing blob hash");
456
457	(name, output_file, hash_output_file)
458}
459
460/// Returns the hash of the code blob.
461///
462/// Should produce the same value as [`pvm_binary_hash`] macro.
463pub fn code_hash(data: &[u8]) -> [u8; 32] {
464	let h = blake2b_simd::Params::new().hash_length(32).hash(data);
465	h.as_bytes().try_into().expect("Hash length set to 32")
466}
467
468fn get_job_server_client() -> Option<&'static jobserver::Client> {
469	static CLIENT: OnceLock<Option<jobserver::Client>> = OnceLock::new();
470	CLIENT.get_or_init(|| unsafe { jobserver::Client::from_env() }).as_ref()
471}
472
473/// Returns the resulting blob as a byte slice.
474#[macro_export]
475macro_rules! pvm_binary {
476	($name:literal) => {
477		include_bytes!(env!(concat!("PVM_BINARY_", $name)))
478	};
479}
480
481/// Returns the resulting blob hash as a byte slice.
482///
483/// Should produce the same value as [`code_hash`] but at compile time, i.e. `pvm_binary_hash!(name)
484/// == &code_hash(pvm_binary!(name))`.
485#[macro_export]
486macro_rules! pvm_binary_hash {
487	($name:literal) => {
488		include_bytes!(env!(concat!("PVM_BINARY_HASH_", $name)))
489	};
490}