Files
scaffold/src/circuits.rs
T

323 lines
13 KiB
Rust

//! On-demand provisioning of the `logos-blockchain-circuits` release artefact
//! that LEZ's `logos-blockchain-*` transitive crates need at build time.
//!
//! The build scripts of `logos-blockchain-pol`, `-poc`, `-poq`, and `-zksign`
//! call into `logos-blockchain-circuits-utils::circuits_dir()`, which panics
//! unless one of these is present:
//!
//! 1. `LOGOS_BLOCKCHAIN_CIRCUITS` env var pointing to a circuits directory.
//! 2. `~/.logos-blockchain-circuits/` populated with a circuits release.
//!
//! Upstream's only documented installation path is the LEZ Nix flake — it
//! consumes the `logos-blockchain-circuits` flake, which fetches the matching
//! tagged release tarball from GitHub Releases. Scaffold drives a plain
//! `cargo build`, so without help the standalone-sequencer build (and the
//! user-project build, since the patch table redirects the same crates)
//! fails on a fresh machine.
//!
//! `ensure_circuits_for_subprocess` materialises the matching release tarball
//! into the scaffold cache and exports `LOGOS_BLOCKCHAIN_CIRCUITS` for the
//! rest of the process. It's idempotent: a populated cache dir or a pre-set
//! env var both short-circuit the download.
//!
//! Why we always export the env var rather than relying on
//! `~/.logos-blockchain-circuits/`: every `logos-blockchain` rev we depend on
//! today (LEZ rc1's pinned `81dbb45…` AND spel rc.5's vendored `5510f55…`)
//! ships its own copy of `circuits-utils`, and the home-dir branch of
//! `circuits_dir()` `assert!`s that the directory's `VERSION` matches that
//! crate's hard-coded `EXPECTED_CIRCUITS_VERSION`. Those constants disagree
//! across the two revs (`v0.4.1` vs. `v0.4.2`), so a single home-dir install
//! can never satisfy both at once. The env-var branch skips the version
//! assertion entirely and just uses the path, which is exactly the escape
//! hatch we need: scaffold-managed projects only consume the verification
//! keys at compile time, and both revs are content-compatible at the file
//! layout we install.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{anyhow, bail};
use flate2::read::GzDecoder;
use tar::Archive;
use crate::constants::{
CIRCUITS_RELEASE_BASE_URL, DEFAULT_CIRCUITS_VERSION, LOGOS_BLOCKCHAIN_CIRCUITS_ENV,
};
use crate::process::which;
use crate::DynResult;
/// One of the per-circuit subdirectories every release tarball contains. Used
/// as a sentinel for "this directory is a populated circuits release" in
/// cache-hit checks and post-extract verification — picking a single circuit
/// (rather than `VERSION`) keeps the check robust to future releases that
/// might rename the version marker.
const CIRCUITS_SENTINEL_FILE: &str = "pol/verification_key.json";
/// Ensure `LOGOS_BLOCKCHAIN_CIRCUITS` is set in this process so subsequently
/// spawned `cargo` invocations inherit it. No-op when the env var is already
/// exported and points at a populated circuits dir (user override).
///
/// Otherwise: download the matching tagged release into
/// `<cache_root>/circuits/v<ver>-<os>-<arch>` (idempotent) and export the
/// env var pointing there. We deliberately do NOT fall back to
/// `~/.logos-blockchain-circuits/` — see the module docs for why.
pub(crate) fn ensure_circuits_for_subprocess(cache_root: &Path) -> DynResult<()> {
if circuits_path_from_env().is_some() {
return Ok(());
}
let path = ensure_circuits_release(cache_root, DEFAULT_CIRCUITS_VERSION)?;
// SAFETY: `set_var` is `unsafe` from Rust 2024 because it is racy when
// other threads call `env::var`. Scaffold is a single-threaded CLI by the
// time this runs (only the main thread has touched env vars; subprocesses
// are spawned synchronously after this call returns). The alternative —
// threading the path through every `Command::new("cargo")` call site —
// is strictly more invasive without changing the safety story for the
// subprocesses, which inherit `environ` either way.
std::env::set_var(LOGOS_BLOCKCHAIN_CIRCUITS_ENV, &path);
Ok(())
}
fn circuits_path_from_env() -> Option<PathBuf> {
let raw = std::env::var(LOGOS_BLOCKCHAIN_CIRCUITS_ENV).ok()?;
let path = PathBuf::from(raw);
// Defensively ignore obviously-stale env values rather than panic-by-proxy
// inside a downstream build script. If the dir is missing we fall through
// to the cache install path below, which is what the user wants.
if path.join(CIRCUITS_SENTINEL_FILE).is_file() {
Some(path)
} else {
None
}
}
/// Materialise (or hit the cache for) the circuits release at `version`,
/// returning the directory that contains `pol/`, `poc/`, ... — the layout
/// `LOGOS_BLOCKCHAIN_CIRCUITS` consumers expect.
fn ensure_circuits_release(cache_root: &Path, version: &str) -> DynResult<PathBuf> {
let triple = release_triple()?;
let dir = cache_root
.join("circuits")
.join(format!("v{version}-{triple}"));
if dir.join(CIRCUITS_SENTINEL_FILE).is_file() {
return Ok(dir);
}
fs::create_dir_all(&dir)
.map_err(|e| anyhow!("create circuits cache dir {}: {e}", dir.display()))?;
let url = format!(
"{CIRCUITS_RELEASE_BASE_URL}/v{version}/logos-blockchain-circuits-v{version}-{triple}.tar.gz",
);
println!("Downloading logos-blockchain-circuits v{version} ({triple})");
println!(" from {url}");
println!(" into {}", dir.display());
let tarball = download_to_tempfile(&url)?;
let bytes = fs::read(tarball.path())
.map_err(|e| anyhow!("read downloaded tarball {}: {e}", tarball.path().display()))?;
extract_tarball(&bytes, &dir)?;
if !dir.join(CIRCUITS_SENTINEL_FILE).is_file() {
bail!(
"circuits tarball extracted but sentinel `{CIRCUITS_SENTINEL_FILE}` is missing under {}",
dir.display()
);
}
Ok(dir)
}
/// Map (`std::env::consts::OS`, `ARCH`) onto the suffix the
/// `logos-blockchain-circuits` GitHub release tarballs use.
///
/// Only the platforms scaffold itself supports today are listed. macOS aarch64
/// works upstream and is the dev-machine baseline; x86_64 macOS isn't shipped
/// by upstream's flake either, so we surface the same constraint here.
fn release_triple() -> DynResult<&'static str> {
let triple = match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => "linux-x86_64",
("linux", "aarch64") => "linux-aarch64",
("macos", "aarch64") => "macos-aarch64",
(os, arch) => bail!(
"unsupported platform for logos-blockchain-circuits release ({os}/{arch}). \
Set {LOGOS_BLOCKCHAIN_CIRCUITS_ENV} to a circuits checkout to bypass."
),
};
Ok(triple)
}
/// Stream `url` to a temp file via `curl`. We deliberately shell out instead
/// of using the in-tree `ureq`-based HTTP client: ureq's bundled rustls trusts
/// only the Mozilla CA set baked into `webpki-roots`, which fails on hosts
/// behind a corporate-CA TLS-intercepting proxy (a common CI shape, and the
/// shape several `lez-framework` early adopters reported). `curl` reads the
/// system trust store (and respects `CURL_CA_BUNDLE` / `SSL_CERT_FILE`), so
/// it works on both vanilla machines and proxied environments.
///
/// Returns the temp file holding the downloaded bytes; the caller reads from
/// it, then drops it so the temp is reaped.
fn download_to_tempfile(url: &str) -> DynResult<tempfile::NamedTempFile> {
if which("curl").is_none() {
bail!(
"`curl` is required to fetch the {} release tarball but is not on PATH. \
Install curl, or set {LOGOS_BLOCKCHAIN_CIRCUITS_ENV} to a pre-extracted \
circuits directory to bypass the download.",
"logos-blockchain-circuits",
);
}
let tmp =
tempfile::NamedTempFile::new().map_err(|e| anyhow!("create download temp file: {e}"))?;
let status = Command::new("curl")
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--retry")
.arg("3")
.arg("--retry-delay")
.arg("2")
.arg("--output")
.arg(tmp.path())
.arg(url)
// Inherit stderr so curl's `--show-error` output reaches the user's
// terminal verbatim — matches scaffold's other shell-out patterns.
.stderr(Stdio::inherit())
.stdout(Stdio::null())
.status()
.map_err(|e| anyhow!("spawn curl for {url}: {e}"))?;
if !status.success() {
bail!("curl failed to download {url} ({status})");
}
let len = tmp
.as_file()
.metadata()
.map(|m| m.len())
.unwrap_or_default();
if len == 0 {
bail!("downloaded {url} is empty");
}
Ok(tmp)
}
/// Extract a `.tar.gz` into `dest`, stripping the single top-level directory
/// the release tarballs ship (`logos-blockchain-circuits-vX.Y.Z-os-arch/...`)
/// so callers can point `LOGOS_BLOCKCHAIN_CIRCUITS` at `dest` directly.
fn extract_tarball(bytes: &[u8], dest: &Path) -> DynResult<()> {
let mut archive = Archive::new(GzDecoder::new(bytes));
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_path_buf();
let mut components = path.components();
// Drop the single top-level dir from every entry. If a tarball ever
// ships without one (e.g. plain files at the root), preserve the
// original layout — `unpack` will fall back to using `path`.
components.next();
let stripped: PathBuf = components.as_path().to_path_buf();
let target = if stripped.as_os_str().is_empty() {
// Top-level directory entry itself; the strip leaves nothing to
// create, and `entry.unpack` would otherwise try to write to
// `dest` directly.
continue;
} else {
dest.join(&stripped)
};
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
entry.unpack(&target)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn make_tarball() -> Vec<u8> {
let mut header = tar::Header::new_gnu();
header.set_size(8);
header.set_mode(0o644);
header.set_cksum();
let mut tar_bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_bytes);
builder
.append_data(
&mut header.clone(),
"release-root/pol/verification_key.json",
&b"{\"k\":1}"[..],
)
.unwrap();
// Same payload size, different file. Header reuse keeps the size
// consistent without recomputing checksum metadata.
builder
.append_data(
&mut header.clone(),
"release-root/zksign/verification_key.json",
&b"{\"k\":2}"[..],
)
.unwrap();
builder.finish().unwrap();
}
let mut gz = Vec::new();
{
let mut encoder =
flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default());
encoder.write_all(&tar_bytes).unwrap();
encoder.finish().unwrap();
}
gz
}
#[test]
fn extract_tarball_strips_release_root_dir() {
let tmp = tempfile::tempdir().unwrap();
extract_tarball(&make_tarball(), tmp.path()).unwrap();
assert!(tmp.path().join("pol/verification_key.json").is_file());
assert!(tmp.path().join("zksign/verification_key.json").is_file());
// The release-root prefix must not survive — otherwise
// `LOGOS_BLOCKCHAIN_CIRCUITS=<dest>` wouldn't point at `pol/...`.
assert!(!tmp.path().join("release-root").exists());
}
#[test]
fn ensure_circuits_release_short_circuits_when_sentinel_present() {
let tmp = tempfile::tempdir().unwrap();
let triple = release_triple().expect("supported test platform");
let preinstalled = tmp.path().join("circuits").join(format!("v9.9.9-{triple}"));
fs::create_dir_all(preinstalled.join("pol")).unwrap();
fs::write(preinstalled.join(CIRCUITS_SENTINEL_FILE), "{}").unwrap();
let resolved = ensure_circuits_release(tmp.path(), "9.9.9").expect("cache hit");
assert_eq!(resolved, preinstalled);
}
#[test]
fn circuits_path_from_env_rejects_unpopulated_dir() {
let tmp = tempfile::tempdir().unwrap();
// Saved/restored guard so this test doesn't leak an env var into
// sibling tests in the same process.
let prev = std::env::var(LOGOS_BLOCKCHAIN_CIRCUITS_ENV).ok();
std::env::set_var(LOGOS_BLOCKCHAIN_CIRCUITS_ENV, tmp.path());
let resolved = circuits_path_from_env();
match prev {
Some(v) => std::env::set_var(LOGOS_BLOCKCHAIN_CIRCUITS_ENV, v),
None => std::env::remove_var(LOGOS_BLOCKCHAIN_CIRCUITS_ENV),
}
assert!(
resolved.is_none(),
"stale env-var dirs must not be returned — fall through to install path"
);
}
}