From e227226e86bc86b050465161ea60e201984585a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 00:15:45 +0000 Subject: [PATCH 1/6] feat(zcash_local_net)!: run managed processes from container images, with an artifact manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every managed process (zebrad Validator, zainod Indexer, zcash-devtool Wallet) can now run from a container image via the docker or podman CLI. Launch configs gain a `source: ArtifactSource` field whose default is the historical TEST_BINARIES_DIR / PATH resolution; the alternatives are an explicit local-build path (the escape hatch for artifacts built from source) or a container image. Container mode is a spawn-command substitution, not a parallel implementation: the container runs in the foreground (`run --rm`) so its stdio streams through the child the harness already manages, joins the host network namespace, and has the harness's temp config/data dirs bind-mounted at identical paths. Readiness-indicator scanning, launch-log endpoint discovery, port-collision retry, the front proxies, and Indexer-convergence parsing are therefore shared verbatim between host and container modes. Stopping a containerized process force-removes the container by name; wallet operations each run as their own one-shot container. Consumers describe where artifacts come from with a small JSON artifact manifest (`container::manifest::ArtifactManifest`), loadable from the ZCASH_LOCAL_NET_MANIFEST environment variable, and validate it as a pre-check with `ArtifactManifest::preflight` — runtime client present and daemon reachable, images present/pulled per their pull policy, host binaries resolvable and executable — reported all at once, before any launch. The new `zcash-local-net` CLI exposes the same pre-check from a shell (`zcash-local-net preflight` / `zcash-local-net template`) for use ahead of `cargo nextest run`. Breaking: `ZebradConfig`, `ZainodConfig` and `ZcashDevtoolConfig` gain the public `source` field (struct-literal constructors must add it); `Zebrad::stop` now tolerates an already-reaped child like `Zainod::stop` does. The container-run CLI contract (flag shape, identical-path volume syntax, entrypoint default, --pull mapping) is pinned by unit tests; the full container launch path was validated live against a local scratch image (foreground log streaming, host-loopback reachability through the front, rm --force teardown, stdin piping, mount ownership). A manifest-driven live integration test is provided ignored, for environments that supply real images. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015dRfXaAyhrUC5LwgEGHaNi --- zcash_local_net/CHANGELOG.md | 54 ++ zcash_local_net/src/bin/zcash-local-net.rs | 146 ++++++ zcash_local_net/src/container.rs | 549 ++++++++++++++++++++ zcash_local_net/src/container/manifest.rs | 525 +++++++++++++++++++ zcash_local_net/src/container/preflight.rs | 393 ++++++++++++++ zcash_local_net/src/indexer/zainod.rs | 62 ++- zcash_local_net/src/lib.rs | 25 + zcash_local_net/src/validator/zebrad.rs | 76 ++- zcash_local_net/src/wallet/zcash_devtool.rs | 23 +- zcash_local_net/tests/containers.rs | 50 ++ 10 files changed, 1884 insertions(+), 19 deletions(-) create mode 100644 zcash_local_net/src/bin/zcash-local-net.rs create mode 100644 zcash_local_net/src/container.rs create mode 100644 zcash_local_net/src/container/manifest.rs create mode 100644 zcash_local_net/src/container/preflight.rs create mode 100644 zcash_local_net/tests/containers.rs diff --git a/zcash_local_net/CHANGELOG.md b/zcash_local_net/CHANGELOG.md index e830561..f29d645 100644 --- a/zcash_local_net/CHANGELOG.md +++ b/zcash_local_net/CHANGELOG.md @@ -7,8 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Containerized artifacts.** Every managed process (the zebrad + Validator, the zainod Indexer, the zcash-devtool Wallet) can now run + from a container image via the `docker` or `podman` CLI instead of a + host binary. Launch configs gained a `source: + container::ArtifactSource` field (default: the historical + `TEST_BINARIES_DIR` / `PATH` resolution, so existing callers are + unaffected). Container mode changes *nothing else*: the container + runs in the **foreground** (`run --rm`) so its stdio streams through + the child the harness already manages, joins the **host network + namespace**, and has the harness's temp config/data dirs + bind-mounted at identical paths — readiness-indicator scanning, + launch-log endpoint discovery, port-collision retry, the front + proxies, and Indexer-convergence parsing are shared verbatim with + host processes. Host networking makes container mode Linux-first + (Docker Desktop's VM loopback is not reachable from the host). + Wallet operations in container mode each run as their own one-shot + container. Stopping a containerized process force-removes the + container by name (`zcash-local-net---`). +- **Artifact manifest** (`container::manifest::ArtifactManifest`): a + small JSON file consumers keep with their test setup describing + where each artifact comes from — an `image` (with optional + `entrypoint` and `pull` policy `never`/`if-missing`/`always`) or a + `local` path to a locally built binary (the **escape hatch**; also + available programmatically as `ArtifactSource::local_binary`). + `ArtifactManifest::from_env()` loads the file named by + `ZCASH_LOCAL_NET_MANIFEST`; `launch_local_net()` launches the core + stack from it; `zebrad_config()` / `zainod_config()` / + `wallet_source()` seed individual configs for customized launches. +- **Preflight pre-check** (`ArtifactManifest::preflight`): validates a + manifest against the current environment before any launch — runtime + client present and daemon reachable, images present locally (pulled + per their pull policy), host binaries resolvable and executable — + and reports every problem at once instead of as buried launch + failures. +- **`zcash-local-net` CLI** (new binary in this crate): + `zcash-local-net preflight [--manifest ]` runs the pre-check + from a shell (exit 0/1), resolving the manifest from the flag, then + `$ZCASH_LOCAL_NET_MANIFEST`, then `./zcash-local-net.json`, then the + built-in host-process default; `zcash-local-net template` prints a + starting-point manifest. Intended use: + `zcash-local-net preflight --manifest m.json && + ZCASH_LOCAL_NET_MANIFEST=m.json cargo nextest run`. + ### Changed +- **Breaking** — `ZebradConfig`, `ZainodConfig`, and + `ZcashDevtoolConfig` gained the public `source` field described + above. Callers constructing these configs with struct literals must + add `source: Default::default()` (or use `..Default::default()`); + callers using `Default` and the builder methods are unaffected. +- **Breaking** — `Zebrad::stop` no longer panics when the child was + already reaped (`InvalidInput` from `kill`), matching `Zainod::stop`; + in container mode the child is the runtime client and routinely + exits when its container is removed first. - **Breaking, read this one** — **every published port and address accessor of the core stack now returns a front proxy, not the process's own listener.** A transparent TCP relay (the *front*) diff --git a/zcash_local_net/src/bin/zcash-local-net.rs b/zcash_local_net/src/bin/zcash-local-net.rs new file mode 100644 index 0000000..51db32e --- /dev/null +++ b/zcash_local_net/src/bin/zcash-local-net.rs @@ -0,0 +1,146 @@ +//! `zcash-local-net` — the harness's artifact pre-check CLI. +//! +//! Test suites that consume `zcash_local_net` describe where their +//! artifacts (zebrad, zainod, zcash-devtool) come from in a manifest +//! file; this CLI validates that manifest against the current +//! environment *before* the test runner starts, so a missing image or +//! binary is one clear report instead of N launch failures: +//! +//! ```sh +//! zcash-local-net preflight --manifest ci-artifacts.json \ +//! && ZCASH_LOCAL_NET_MANIFEST=ci-artifacts.json cargo nextest run +//! ``` +//! +//! Argument parsing is hand-rolled: two subcommands and one flag do +//! not justify a CLI-parser dependency in the library crate. + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use zcash_local_net::container::manifest::{ + ArtifactManifest, DEFAULT_MANIFEST_FILENAME, MANIFEST_ENV_VAR, +}; + +const USAGE: &str = "\ +zcash-local-net — artifact pre-checks for the zcash_local_net harness + +USAGE: + zcash-local-net preflight [--manifest ] + zcash-local-net template + zcash-local-net help + +COMMANDS: + preflight Validate the artifact manifest against this machine: + container runtime present and reachable, images + present (pulled per their pull policy), host binaries + resolvable and executable. Exits 0 when every check + passes, 1 otherwise. + template Print an example manifest (JSON) to stdout. + help Print this message. + +MANIFEST RESOLUTION (for `preflight`): + 1. --manifest + 2. $ZCASH_LOCAL_NET_MANIFEST + 3. ./zcash-local-net.json, if it exists + 4. none — the built-in default (every artifact resolved as a host + process via TEST_BINARIES_DIR / PATH) +"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("preflight") => preflight(&args[1..]), + Some("template") => { + print!("{}", ArtifactManifest::template_json()); + ExitCode::SUCCESS + } + Some("help") | Some("--help") | Some("-h") => { + print!("{USAGE}"); + ExitCode::SUCCESS + } + Some(other) => { + eprintln!("unknown command {other:?}\n\n{USAGE}"); + ExitCode::from(2) + } + None => { + eprint!("{USAGE}"); + ExitCode::from(2) + } + } +} + +fn preflight(args: &[String]) -> ExitCode { + let manifest_path = match parse_manifest_flag(args) { + Ok(path) => path, + Err(message) => { + eprintln!("{message}\n\n{USAGE}"); + return ExitCode::from(2); + } + }; + + let (manifest, provenance) = match resolve_manifest(manifest_path) { + Ok(resolved) => resolved, + Err(error) => { + eprintln!("error: {error}"); + return ExitCode::FAILURE; + } + }; + println!("manifest: {provenance}"); + + // The library is async throughout; the CLI hosts a minimal + // current-thread runtime for the one call. + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("the preflight runtime should build"); + let report = runtime.block_on(manifest.preflight()); + print!("{report}"); + if report.passed() { + println!("preflight passed"); + ExitCode::SUCCESS + } else { + println!("preflight FAILED"); + ExitCode::FAILURE + } +} + +/// Parse `[--manifest ]`, rejecting anything else. +fn parse_manifest_flag(args: &[String]) -> Result, String> { + match args { + [] => Ok(None), + [flag, path] if flag == "--manifest" => Ok(Some(PathBuf::from(path))), + [flag] if flag == "--manifest" => Err("--manifest requires a path".to_string()), + other => Err(format!("unexpected arguments: {other:?}")), + } +} + +/// Apply the documented manifest-resolution order, reporting which +/// source won so the user knows what was actually checked. +fn resolve_manifest( + explicit: Option, +) -> Result<(ArtifactManifest, String), Box> { + if let Some(path) = explicit { + let manifest = ArtifactManifest::load(&path)?; + return Ok((manifest, format!("{} (--manifest)", path.display()))); + } + if let Some(path) = std::env::var_os(MANIFEST_ENV_VAR) { + let path = PathBuf::from(path); + let manifest = ArtifactManifest::load(&path)?; + return Ok(( + manifest, + format!("{} (${MANIFEST_ENV_VAR})", path.display()), + )); + } + let cwd_manifest = Path::new(DEFAULT_MANIFEST_FILENAME); + if cwd_manifest.exists() { + let manifest = ArtifactManifest::load(cwd_manifest)?; + return Ok(( + manifest, + format!("./{DEFAULT_MANIFEST_FILENAME} (working directory)"), + )); + } + Ok(( + ArtifactManifest::default(), + "built-in default (host processes via TEST_BINARIES_DIR / PATH)".to_string(), + )) +} diff --git a/zcash_local_net/src/container.rs b/zcash_local_net/src/container.rs new file mode 100644 index 0000000..8eb3b1e --- /dev/null +++ b/zcash_local_net/src/container.rs @@ -0,0 +1,549 @@ +//! Containerized artifacts: run the managed processes from container +//! images instead of host binaries. +//! +//! Every process this crate manages (the zebrad Validator, the zainod +//! Indexer, the zcash-devtool Wallet) is spawned through one choke +//! point: an [`ArtifactSource`] on its launch config. The default, +//! [`ArtifactSource::HostProcess`], is the classic behavior — resolve +//! the binary via `TEST_BINARIES_DIR` / `PATH` (or an explicit path, +//! the escape hatch for locally built binaries). The alternative, +//! [`ArtifactSource::Container`], runs the same binary out of a +//! container image via the `docker` or `podman` CLI. +//! +//! Container mode deliberately changes *nothing else*: the container +//! is launched in the **foreground** (`run --rm`, no `--detach`), so +//! its stdout/stderr stream through the runtime client — the child +//! process the harness already manages — and every existing mechanism +//! (readiness-indicator scanning, launch-log endpoint discovery, +//! port-collision retry, front proxies, Indexer-convergence parsing) +//! operates on containers exactly as it does on host processes. +//! Containers join the **host network namespace** (`--network host`) +//! and the harness's temporary config/data directories are +//! bind-mounted at identical paths, so the generated config files are +//! valid verbatim inside the container and all loopback wiring (the +//! front proxies, the Indexer→Validator connection) is unchanged. +//! Host networking makes this mode Linux-first: on Docker Desktop +//! (macOS/Windows) loopback listeners inside the VM are not reachable +//! from the host. +//! +//! Consumers describe which artifacts come from where with an +//! [`manifest::ArtifactManifest`] — a small JSON file that can be +//! validated ahead of a test run with [`manifest::ArtifactManifest::preflight`] +//! (also exposed by the `zcash-local-net` CLI as `zcash-local-net +//! preflight`). +//! +//! ## Lifecycle and cleanup +//! +//! A foreground `run --rm` container is removed by the runtime when +//! its process exits. Stopping a managed process in container mode +//! additionally force-removes the container by name (`rm --force`), +//! because killing the *client* process alone would leave the +//! container running. Containers are named +//! `zcash-local-net---`, so stragglers from a killed +//! harness process can be cleaned up with e.g. +//! `docker ps -aq --filter name=zcash-local-net- | xargs docker rm -f`. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::utils::executable_finder::pick_command; + +pub mod manifest; +pub mod preflight; + +/// The container runtime CLI used to run [`ArtifactSource::Container`] +/// artifacts. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ContainerRuntime { + /// The `docker` CLI. + Docker, + /// The `podman` CLI. + Podman, +} + +impl ContainerRuntime { + /// The CLI executable name for this runtime. + pub fn cli_name(&self) -> &'static str { + match self { + Self::Docker => "docker", + Self::Podman => "podman", + } + } + + /// Detect an available container runtime by probing `docker + /// --version` then `podman --version`. Returns the first CLI that + /// runs successfully, or `None` when neither is installed. Client + /// presence only — daemon reachability is a preflight check + /// ([`manifest::ArtifactManifest::preflight`]). + pub fn detect() -> Option { + [Self::Docker, Self::Podman] + .into_iter() + .find(|runtime| runtime.client_available()) + } + + /// Whether ` --version` runs and exits successfully. + pub(crate) fn client_available(&self) -> bool { + Command::new(self.cli_name()) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) + } + + /// Parse the manifest's `runtime` string. + pub(crate) fn from_manifest_name(name: &str) -> Option { + match name { + "docker" => Some(Self::Docker), + "podman" => Some(Self::Podman), + _ => None, + } + } +} + +impl std::fmt::Display for ContainerRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.cli_name()) + } +} + +/// When a container image is (re)fetched from its registry. +/// +/// Enforced both at preflight (an explicit `pull` step) and at launch +/// (mapped to the runtime's `run --pull=` flag, so a launch +/// can never silently pull an image the policy forbids). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PullPolicy { + /// Never pull: the image must already be present locally. The + /// right policy for hermetic CI caches and for images built + /// locally from a source checkout. + Never, + /// Pull only when the image is not present locally (the default). + #[default] + IfMissing, + /// Always pull, refreshing a floating tag like `latest`. + Always, +} + +impl PullPolicy { + /// The value for the runtime's `run --pull=` flag. + fn as_run_flag_value(&self) -> &'static str { + match self { + Self::Never => "never", + Self::IfMissing => "missing", + Self::Always => "always", + } + } + + /// Parse the manifest's `pull` string. + pub(crate) fn from_manifest_name(name: &str) -> Option { + match name { + "never" => Some(Self::Never), + "if-missing" => Some(Self::IfMissing), + "always" => Some(Self::Always), + _ => None, + } + } +} + +/// A container image an artifact runs from. +#[derive(Clone, Debug)] +pub struct ContainerImage { + /// The runtime CLI that runs this image. + pub runtime: ContainerRuntime, + /// Image reference (`repository[:tag][@digest]`), passed verbatim + /// to the runtime. + pub image: String, + /// Entrypoint override. `None` uses the artifact's executable name + /// (`zebrad`, `zainod`, `zcash-devtool`), which deliberately + /// bypasses image entrypoint scripts — the harness writes complete + /// config files itself and wants the bare binary. + pub entrypoint: Option, + /// When the image is (re)fetched from its registry. + pub pull: PullPolicy, +} + +/// Where an artifact (a managed binary) comes from. +/// +/// Every launch config carries one of these; the default is the +/// classic host-process resolution, so existing callers are +/// unaffected. +#[derive(Clone, Debug)] +pub enum ArtifactSource { + /// Run the binary as a host process. + /// + /// `binary: None` resolves via the `TEST_BINARIES_DIR` environment + /// variable, falling back to `PATH` — exactly the historical + /// behavior. `binary: Some(path)` runs that path directly: the + /// **escape hatch** for a Validator or Indexer built locally from + /// source (e.g. `Some("…/zebra/target/release/zebrad".into())`). + HostProcess { + /// Explicit path to the binary, or `None` to resolve via + /// `TEST_BINARIES_DIR` / `PATH`. + binary: Option, + }, + /// Run the binary from a container image (foreground, host + /// network, harness dirs bind-mounted — see the module docs). + Container(ContainerImage), +} + +impl Default for ArtifactSource { + /// The historical behavior: resolve the binary via + /// `TEST_BINARIES_DIR` / `PATH`. + fn default() -> Self { + Self::HostProcess { binary: None } + } +} + +impl ArtifactSource { + /// Convenience constructor for the local-build escape hatch. + pub fn local_binary(path: impl Into) -> Self { + Self::HostProcess { + binary: Some(path.into()), + } + } + + /// Whether this source runs from a container image. + pub fn is_container(&self) -> bool { + matches!(self, Self::Container(_)) + } + + /// Mint the uniquely-named container instance a daemon launch in + /// container mode will run as (and be force-removed by name + /// through). `None` in host-process mode, and for the one-shot + /// wallet operations, which need no name. + pub(crate) fn new_instance(&self, executable_name: &str) -> Option { + match self { + Self::HostProcess { .. } => None, + Self::Container(image) => Some(ContainerInstance { + runtime: image.runtime, + name: unique_container_name(executable_name), + }), + } + } + + /// Build the command that spawns this artifact — the single choke + /// point through which every managed process is launched. The + /// caller appends the binary's own arguments afterwards, identical + /// in both modes. + pub(crate) fn command(&self, spec: &LaunchSpec<'_>) -> Command { + match self { + Self::HostProcess { binary: None } => pick_command(spec.executable_name, false), + Self::HostProcess { binary: Some(path) } => Command::new(path), + Self::Container(image) => container_run_command(image, spec), + } + } +} + +/// How an artifact spawn should be shaped, beyond the binary's own +/// arguments. +pub(crate) struct LaunchSpec<'a> { + /// The binary's executable name — the host-mode lookup key and the + /// container-mode default entrypoint. + pub executable_name: &'static str, + /// Container name for daemon launches (minted by + /// [`ArtifactSource::new_instance`]); `None` for one-shot + /// invocations, which rely on `--rm` alone. + pub container_name: Option<&'a str>, + /// Host directories bind-mounted into the container at identical + /// paths, so harness-written config files are valid verbatim + /// inside. Ignored in host mode. + pub mounts: &'a [&'a Path], + /// Keep stdin open (`--interactive`) so the caller can pipe to it + /// (the wallet's `init` receives its mnemonic on stdin). Ignored + /// in host mode — a host `Command` pipes stdin without ceremony. + pub interactive: bool, +} + +impl<'a> LaunchSpec<'a> { + /// A spec with no mounts, no name, and no stdin — version traces + /// and other one-shot probes. + pub(crate) fn bare(executable_name: &'static str) -> Self { + Self { + executable_name, + container_name: None, + mounts: &[], + interactive: false, + } + } +} + +/// Assemble `docker|podman run --rm [--name N] --pull=P --network host +/// [--user uid:gid] [-i] --entrypoint E -v p:p… IMAGE`. +/// +/// `--network host` keeps every loopback assumption of the process +/// harness true inside the container (see the module docs); `--user` +/// runs the containerized binary as the harness's own uid/gid so it +/// can write the bind-mounted temp dirs and the files it creates there +/// are owned by the harness. +fn container_run_command(image: &ContainerImage, spec: &LaunchSpec<'_>) -> Command { + let mut command = Command::new(image.runtime.cli_name()); + command.args(["run", "--rm"]); + if let Some(name) = spec.container_name { + command.args(["--name", name]); + } + command.arg(format!("--pull={}", image.pull.as_run_flag_value())); + command.args(["--network", "host"]); + if let Some((uid, gid)) = current_uid_gid() { + command.arg("--user").arg(format!("{uid}:{gid}")); + } + if spec.interactive { + command.arg("--interactive"); + } + command + .arg("--entrypoint") + .arg(image.entrypoint.as_deref().unwrap_or(spec.executable_name)); + for mount in spec.mounts { + command + .arg("--volume") + .arg(format!("{}:{}", mount.display(), mount.display())); + } + command.arg(&image.image); + command +} + +/// The harness process's `(uid, gid)`, learned from the ownership of a +/// freshly created temp dir (no libc dependency). Cached for the +/// process lifetime. `None` on non-Unix targets or if the probe fails, +/// in which case `--user` is omitted and the image's default user +/// applies. +fn current_uid_gid() -> Option<(u32, u32)> { + static CACHED: OnceLock> = OnceLock::new(); + *CACHED.get_or_init(|| { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + let probe = tempfile::tempdir().ok()?; + let metadata = std::fs::metadata(probe.path()).ok()?; + Some((metadata.uid(), metadata.gid())) + } + #[cfg(not(unix))] + { + None + } + }) +} + +/// Monotonic counter distinguishing containers launched by one harness +/// process. +static CONTAINER_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// `zcash-local-net---`: unique per launch within a +/// host, greppable for cleanup of stragglers. +fn unique_container_name(executable_name: &str) -> String { + format!( + "zcash-local-net-{executable_name}-{}-{}", + std::process::id(), + CONTAINER_COUNTER.fetch_add(1, Ordering::Relaxed), + ) +} + +/// A named container a daemon launch is running as. Held by the +/// process wrapper so `stop` can force-remove the container — killing +/// the foreground runtime *client* alone would leave the container +/// running. +#[derive(Debug)] +pub(crate) struct ContainerInstance { + runtime: ContainerRuntime, + name: String, +} + +impl ContainerInstance { + /// The container's `--name`. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// `docker|podman rm --force `, blocking and best-effort. + /// A "no such container" failure is the goal state (the container + /// already exited and `--rm` reaped it) and is only traced at + /// debug level; any other failure is surfaced as a warning — the + /// caller is tearing down and has nothing better to do with the + /// error than report it. + pub(crate) fn force_remove(&self) { + match Command::new(self.runtime.cli_name()) + .args(["rm", "--force", &self.name]) + .output() + { + Ok(output) if output.status.success() => {} + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.to_ascii_lowercase().contains("no such container") { + tracing::debug!(container = %self.name, "container already removed"); + } else { + tracing::warn!( + container = %self.name, + %stderr, + "failed to force-remove container" + ); + } + } + Err(io_error) => { + tracing::warn!( + container = %self.name, + %io_error, + "failed to run the container runtime CLI for force-remove" + ); + } + } + } +} + +/// Trace the artifact's version and provenance, in whichever mode the +/// source runs it. The container branch is the source-aware analogue +/// of [`crate::utils::executable_finder::trace_version_and_location`]; +/// failures are traced, not fatal — the subsequent launch produces the +/// authoritative error with full context. +pub(crate) fn trace_version( + source: &ArtifactSource, + executable_name: &'static str, + version_flag: &str, +) { + match source { + ArtifactSource::HostProcess { binary: None } => { + crate::utils::executable_finder::trace_version_and_location( + executable_name, + version_flag, + ); + } + _ => { + let mut command = source.command(&LaunchSpec::bare(executable_name)); + command.arg(version_flag); + match command.output() { + Ok(version) => { + tracing::info!("$ {executable_name} {version_flag} ({source:?})\n {version:?}"); + } + Err(io_error) => tracing::warn!( + %io_error, + "could not probe {executable_name} {version_flag} via {source:?}" + ), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_image(entrypoint: Option<&str>, pull: PullPolicy) -> ContainerImage { + ContainerImage { + runtime: ContainerRuntime::Docker, + image: "example.com/zebra:v6.0.0".to_string(), + entrypoint: entrypoint.map(str::to_string), + pull, + } + } + + fn rendered_args(command: &Command) -> Vec { + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + + /// Pins the container-run CLI contract this module emits: flag + /// set, flag order, identical-path volume syntax, entrypoint + /// default. Consumers script cleanup against the `--name` prefix + /// and the harness relies on `--rm` + foreground semantics, so + /// drift here is behavior drift. + #[test] + fn daemon_run_command_shape() { + let mounts: &[&Path] = &[Path::new("/tmp/cfg"), Path::new("/tmp/data")]; + let source = ArtifactSource::Container(test_image(None, PullPolicy::IfMissing)); + let command = source.command(&LaunchSpec { + executable_name: "zebrad", + container_name: Some("zcash-local-net-zebrad-1-0"), + mounts, + interactive: false, + }); + + assert_eq!(command.get_program(), "docker"); + let args = rendered_args(&command); + let mut expected = vec![ + "run".to_string(), + "--rm".to_string(), + "--name".to_string(), + "zcash-local-net-zebrad-1-0".to_string(), + "--pull=missing".to_string(), + "--network".to_string(), + "host".to_string(), + ]; + if let Some((uid, gid)) = current_uid_gid() { + expected.push("--user".to_string()); + expected.push(format!("{uid}:{gid}")); + } + expected.extend( + [ + "--entrypoint", + "zebrad", + "--volume", + "/tmp/cfg:/tmp/cfg", + "--volume", + "/tmp/data:/tmp/data", + "example.com/zebra:v6.0.0", + ] + .map(str::to_string), + ); + assert_eq!(args, expected); + } + + /// One-shot (wallet-op) shape: no `--name`, `--interactive` when + /// stdin will be piped, explicit entrypoint override respected, + /// and `--pull=never` enforced at run time. + #[test] + fn one_shot_interactive_run_command_shape() { + let mounts: &[&Path] = &[Path::new("/tmp/wallet")]; + let source = ArtifactSource::Container(test_image( + Some("/usr/local/bin/zcash-devtool"), + PullPolicy::Never, + )); + let command = source.command(&LaunchSpec { + executable_name: "zcash-devtool", + container_name: None, + mounts, + interactive: true, + }); + + let args = rendered_args(&command); + assert!(!args.contains(&"--name".to_string())); + assert!(args.contains(&"--interactive".to_string())); + assert!(args.contains(&"--pull=never".to_string())); + let entrypoint_index = args + .iter() + .position(|arg| arg == "--entrypoint") + .expect("entrypoint flag present"); + assert_eq!(args[entrypoint_index + 1], "/usr/local/bin/zcash-devtool"); + assert_eq!(args.last().unwrap(), "example.com/zebra:v6.0.0"); + } + + /// The escape hatch: an explicit binary path is run directly, + /// bypassing `TEST_BINARIES_DIR` / `PATH` resolution entirely. + #[test] + fn explicit_local_binary_is_run_directly() { + let source = ArtifactSource::local_binary("/home/dev/zebra/target/release/zebrad"); + let command = source.command(&LaunchSpec::bare("zebrad")); + assert_eq!( + command.get_program(), + "/home/dev/zebra/target/release/zebrad" + ); + assert_eq!(command.get_args().count(), 0); + } + + /// The default source is the historical host-process resolution. + #[test] + fn default_source_is_host_process_resolution() { + assert!(matches!( + ArtifactSource::default(), + ArtifactSource::HostProcess { binary: None } + )); + } + + #[test] + fn container_names_are_unique_and_greppable() { + let a = unique_container_name("zebrad"); + let b = unique_container_name("zebrad"); + assert_ne!(a, b); + assert!(a.starts_with("zcash-local-net-zebrad-")); + } +} diff --git a/zcash_local_net/src/container/manifest.rs b/zcash_local_net/src/container/manifest.rs new file mode 100644 index 0000000..02e3bfc --- /dev/null +++ b/zcash_local_net/src/container/manifest.rs @@ -0,0 +1,525 @@ +//! The artifact manifest: a consumer-authored description of where +//! each managed artifact (Validator, Indexer, Wallet) comes from. +//! +//! Consumers of this library check a manifest file into their test +//! setup, point the `ZCASH_LOCAL_NET_MANIFEST` environment variable +//! (or a `--manifest` CLI flag) at it, and get the same harness +//! behavior everywhere: CI runs pinned container images, while a +//! developer iterating on zebrad or zaino flips one artifact to a +//! `local` path — the escape hatch — without touching the tests. +//! +//! The format is JSON (this crate already depends on serde_json; +//! adding a TOML parser was rejected to keep the supply-chain surface +//! flat). `zcash-local-net template` prints a starting point. +//! +//! ```json +//! { +//! "version": 1, +//! "runtime": "docker", +//! "validator": { "image": "zfnd/zebra:v6.0.0" }, +//! "indexer": { "local": "/home/dev/zaino/target/release/zainod" }, +//! "wallet": {} +//! } +//! ``` +//! +//! Per artifact (`validator`, `indexer`, `wallet`): +//! +//! - `image`: run from this container image reference. Optional +//! companions: `entrypoint` (defaults to the artifact's binary +//! name) and `pull` (`"never"` / `"if-missing"` / `"always"`, +//! default `"if-missing"`). +//! - `local`: run this host binary directly — the escape hatch for +//! locally built artifacts. Mutually exclusive with `image`. +//! - `{}` or omitted: resolve the binary via `TEST_BINARIES_DIR` / +//! `PATH`, the historical default. +//! +//! Top-level `runtime` (`"docker"` / `"podman"`) is optional; when +//! omitted and any artifact names an `image`, the runtime is +//! auto-detected at load time (docker first, then podman). + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use super::{ArtifactSource, ContainerImage, ContainerRuntime, PullPolicy}; +use crate::{ + LocalNet, error::LaunchError, indexer::zainod::Zainod, indexer::zainod::ZainodConfig, + validator::zebrad::Zebrad, validator::zebrad::ZebradConfig, +}; + +/// The manifest file name looked for in the working directory when no +/// explicit path is given (CLI convention). +pub const DEFAULT_MANIFEST_FILENAME: &str = "zcash-local-net.json"; + +/// Environment variable naming the manifest file test suites should +/// load (see [`ArtifactManifest::from_env`]). +pub const MANIFEST_ENV_VAR: &str = "ZCASH_LOCAL_NET_MANIFEST"; + +/// The only manifest schema version this build understands. +const SUPPORTED_VERSION: u32 = 1; + +/// Errors loading or validating an artifact manifest. +#[derive(thiserror::Error, Debug)] +pub enum ManifestError { + /// The manifest file could not be read. + #[error("could not read manifest {path}: {io_error}")] + Unreadable { + /// Path that was being read. + path: PathBuf, + /// Underlying io::Error description. + io_error: String, + }, + /// The manifest is not valid JSON, or does not match the schema + /// (unknown fields are rejected so typos fail loudly). + #[error("manifest {path:?} did not parse: {detail}")] + Parse { + /// Path of the offending file, or `None` when parsing an + /// in-memory string. + path: Option, + /// serde error description. + detail: String, + }, + /// `version` names a schema this build does not understand. + #[error("unsupported manifest version {found} (this build supports {SUPPORTED_VERSION})")] + UnsupportedVersion { + /// The version the manifest declared. + found: u32, + }, + /// An artifact entry is self-contradictory. + #[error("manifest artifact `{artifact}`: {reason}")] + InvalidArtifact { + /// Which artifact table (`validator` / `indexer` / `wallet`). + artifact: &'static str, + /// What is wrong with it. + reason: String, + }, + /// `runtime` names something other than `docker` or `podman`. + #[error("unknown container runtime {name:?} (expected \"docker\" or \"podman\")")] + UnknownRuntime { + /// The runtime string the manifest declared. + name: String, + }, + /// The manifest names container images but neither `docker` nor + /// `podman` is installed. + #[error( + "manifest names container images but no container runtime was found \ + (probed `docker --version`, then `podman --version`)" + )] + NoContainerRuntime, +} + +/// Serde-facing manifest schema. Unknown fields are rejected so a +/// typo'd key fails at load time instead of silently reverting an +/// artifact to its default source. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawManifest { + version: u32, + runtime: Option, + validator: Option, + indexer: Option, + wallet: Option, +} + +/// Serde-facing artifact entry. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawArtifact { + image: Option, + local: Option, + entrypoint: Option, + pull: Option, +} + +/// A validated artifact manifest: one resolved [`ArtifactSource`] per +/// managed artifact. +/// +/// The `Default` manifest resolves everything as a host process via +/// `TEST_BINARIES_DIR` / `PATH` — byte-for-byte the crate's historical +/// behavior — so `ArtifactManifest::from_env()?.unwrap_or_default()` +/// is a safe drop-in for any test suite. +#[derive(Clone, Debug, Default)] +pub struct ArtifactManifest { + /// Where the Validator (zebrad) comes from. + pub validator: ArtifactSource, + /// Where the Indexer (zainod) comes from. + pub indexer: ArtifactSource, + /// Where the Wallet binary (zcash-devtool) comes from. + pub wallet: ArtifactSource, +} + +impl ArtifactManifest { + /// Load and validate the manifest at `path`. + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|io_error| ManifestError::Unreadable { + path: path.to_path_buf(), + io_error: io_error.to_string(), + })?; + Self::parse(&text, Some(path)) + } + + /// Parse and validate a manifest from a JSON string. + pub fn from_json_str(json: &str) -> Result { + Self::parse(json, None) + } + + /// Load the manifest named by the `ZCASH_LOCAL_NET_MANIFEST` + /// environment variable, or `Ok(None)` when the variable is not + /// set. The intended test-suite entry point: + /// + /// ```no_run + /// # async fn f() -> Result<(), Box> { + /// use zcash_local_net::container::manifest::ArtifactManifest; + /// + /// let manifest = ArtifactManifest::from_env()?.unwrap_or_default(); + /// let report = manifest.preflight().await; + /// assert!(report.passed(), "artifact preflight failed:\n{report}"); + /// let net = manifest.launch_local_net().await?; + /// # Ok(()) } + /// ``` + pub fn from_env() -> Result, ManifestError> { + match std::env::var_os(MANIFEST_ENV_VAR) { + Some(path) => Self::load(Path::new(&path)).map(Some), + None => Ok(None), + } + } + + fn parse(json: &str, path: Option<&Path>) -> Result { + let raw: RawManifest = + serde_json::from_str(json).map_err(|error| ManifestError::Parse { + path: path.map(Path::to_path_buf), + detail: error.to_string(), + })?; + if raw.version != SUPPORTED_VERSION { + return Err(ManifestError::UnsupportedVersion { found: raw.version }); + } + + let declared_runtime = raw + .runtime + .as_deref() + .map(|name| { + ContainerRuntime::from_manifest_name(name).ok_or_else(|| { + ManifestError::UnknownRuntime { + name: name.to_string(), + } + }) + }) + .transpose()?; + + // Resolve the runtime once, lazily: probing for docker/podman + // only happens when some artifact actually names an image. + let needs_runtime = [&raw.validator, &raw.indexer, &raw.wallet] + .into_iter() + .flatten() + .any(|artifact| artifact.image.is_some()); + let runtime = if needs_runtime { + Some(match declared_runtime { + Some(runtime) => runtime, + None => ContainerRuntime::detect().ok_or(ManifestError::NoContainerRuntime)?, + }) + } else { + None + }; + + Ok(Self { + validator: resolve_artifact("validator", raw.validator, runtime)?, + indexer: resolve_artifact("indexer", raw.indexer, runtime)?, + wallet: resolve_artifact("wallet", raw.wallet, runtime)?, + }) + } + + /// A `ZebradConfig` whose artifact source is this manifest's + /// `validator`; every other field keeps its default. + pub fn zebrad_config(&self) -> ZebradConfig { + ZebradConfig { + source: self.validator.clone(), + ..ZebradConfig::default() + } + } + + /// A `ZainodConfig` whose artifact source is this manifest's + /// `indexer`; every other field keeps its default. + pub fn zainod_config(&self) -> ZainodConfig { + ZainodConfig { + source: self.indexer.clone(), + ..ZainodConfig::default() + } + } + + /// The Wallet binary's artifact source, for seeding a wallet + /// config (e.g. `ZcashDevtoolConfig`'s `source` field) inside a + /// `LocalNet::launch_wallet` closure. + pub fn wallet_source(&self) -> ArtifactSource { + self.wallet.clone() + } + + /// Launch the core stack (zebrad Validator + zainod Indexer) with + /// each artifact taken from this manifest and default settings + /// otherwise. For non-default settings, seed configs via + /// [`Self::zebrad_config`] / [`Self::zainod_config`], adjust them, + /// and call [`LocalNet::launch_from_two_configs`] yourself. + /// + /// # Errors + /// Returns `LaunchError` if a process (or its container) fails to + /// launch. Running [`Self::preflight`] first turns most + /// environment problems into precise, pre-launch diagnostics. + pub async fn launch_local_net(&self) -> Result, LaunchError> { + LocalNet::launch_from_two_configs(self.zebrad_config(), self.zainod_config()).await + } + + /// An example manifest, printed by `zcash-local-net template`. + /// Kept parseable by a unit test. + pub fn template_json() -> &'static str { + r#"{ + "version": 1, + "runtime": "docker", + "validator": { + "image": "zfnd/zebra:v6.0.0", + "entrypoint": "zebrad", + "pull": "if-missing" + }, + "indexer": { + "local": "/home/dev/zaino/target/release/zainod" + }, + "wallet": {} +} +"# + } +} + +/// Validate one artifact entry and resolve it to an [`ArtifactSource`]. +fn resolve_artifact( + artifact: &'static str, + raw: Option, + runtime: Option, +) -> Result { + let raw = raw.unwrap_or_default(); + match (raw.image, raw.local) { + (Some(_), Some(_)) => Err(ManifestError::InvalidArtifact { + artifact, + reason: "`image` and `local` are mutually exclusive — pick one".to_string(), + }), + (None, local) => { + if raw.entrypoint.is_some() || raw.pull.is_some() { + return Err(ManifestError::InvalidArtifact { + artifact, + reason: "`entrypoint` and `pull` only apply to `image` artifacts".to_string(), + }); + } + Ok(ArtifactSource::HostProcess { binary: local }) + } + (Some(image), None) => { + let pull = raw + .pull + .as_deref() + .map(|name| { + PullPolicy::from_manifest_name(name).ok_or_else(|| { + ManifestError::InvalidArtifact { + artifact, + reason: format!( + "unknown pull policy {name:?} \ + (expected \"never\", \"if-missing\" or \"always\")" + ), + } + }) + }) + .transpose()? + .unwrap_or_default(); + Ok(ArtifactSource::Container(ContainerImage { + // `needs_runtime` in the caller guarantees Some here. + runtime: runtime.expect("runtime resolved when any artifact names an image"), + image, + entrypoint: raw.entrypoint, + pull, + })) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_manifest_resolves_everything_to_host_defaults() { + let manifest = ArtifactManifest::from_json_str(r#"{ "version": 1 }"#).unwrap(); + for source in [&manifest.validator, &manifest.indexer, &manifest.wallet] { + assert!(matches!( + source, + ArtifactSource::HostProcess { binary: None } + )); + } + } + + #[test] + fn local_escape_hatch_resolves_to_explicit_binary() { + let manifest = ArtifactManifest::from_json_str( + r#"{ + "version": 1, + "indexer": { "local": "/builds/zainod" } + }"#, + ) + .unwrap(); + let ArtifactSource::HostProcess { + binary: Some(binary), + } = &manifest.indexer + else { + panic!("expected explicit host binary, got {:?}", manifest.indexer); + }; + assert_eq!(binary, Path::new("/builds/zainod")); + // The untouched artifacts stay on the historical default. + assert!(matches!( + manifest.validator, + ArtifactSource::HostProcess { binary: None } + )); + } + + #[test] + fn image_artifact_carries_entrypoint_and_pull_policy() { + let manifest = ArtifactManifest::from_json_str( + r#"{ + "version": 1, + "runtime": "podman", + "validator": { + "image": "example.com/zebra:v6.0.0", + "entrypoint": "/usr/bin/zebrad", + "pull": "never" + } + }"#, + ) + .unwrap(); + let ArtifactSource::Container(image) = &manifest.validator else { + panic!("expected container source, got {:?}", manifest.validator); + }; + assert_eq!(image.runtime, ContainerRuntime::Podman); + assert_eq!(image.image, "example.com/zebra:v6.0.0"); + assert_eq!(image.entrypoint.as_deref(), Some("/usr/bin/zebrad")); + assert_eq!(image.pull, PullPolicy::Never); + } + + #[test] + fn image_and_local_are_mutually_exclusive() { + let error = ArtifactManifest::from_json_str( + r#"{ + "version": 1, + "runtime": "docker", + "validator": { "image": "zebra", "local": "/builds/zebrad" } + }"#, + ) + .unwrap_err(); + assert!(matches!( + error, + ManifestError::InvalidArtifact { + artifact: "validator", + .. + } + )); + } + + #[test] + fn image_only_fields_are_rejected_on_host_artifacts() { + let error = ArtifactManifest::from_json_str( + r#"{ + "version": 1, + "wallet": { "local": "/builds/zcash-devtool", "pull": "always" } + }"#, + ) + .unwrap_err(); + assert!(matches!( + error, + ManifestError::InvalidArtifact { + artifact: "wallet", + .. + } + )); + } + + #[test] + fn unknown_keys_fail_loudly() { + let error = ArtifactManifest::from_json_str( + r#"{ "version": 1, "validatr": { "image": "zebra" } }"#, + ) + .unwrap_err(); + assert!(matches!(error, ManifestError::Parse { .. }), "{error:?}"); + } + + #[test] + fn unsupported_version_is_rejected() { + let error = ArtifactManifest::from_json_str(r#"{ "version": 2 }"#).unwrap_err(); + assert!(matches!( + error, + ManifestError::UnsupportedVersion { found: 2 } + )); + } + + #[test] + fn unknown_runtime_is_rejected() { + let error = ArtifactManifest::from_json_str( + r#"{ "version": 1, "runtime": "containerd", "validator": { "image": "zebra" } }"#, + ) + .unwrap_err(); + assert!(matches!(error, ManifestError::UnknownRuntime { name } if name == "containerd")); + } + + #[test] + fn unknown_pull_policy_is_rejected() { + let error = ArtifactManifest::from_json_str( + r#"{ + "version": 1, + "runtime": "docker", + "validator": { "image": "zebra", "pull": "sometimes" } + }"#, + ) + .unwrap_err(); + assert!(matches!( + error, + ManifestError::InvalidArtifact { + artifact: "validator", + .. + } + )); + } + + /// A manifest with no `image` artifacts must never probe for a + /// container runtime — the manifest (and `Default`) must work on + /// hosts with neither docker nor podman installed. Indirectly + /// pinned here by resolving a local-only manifest successfully + /// regardless of what's installed. + #[test] + fn local_only_manifest_needs_no_runtime() { + let manifest = ArtifactManifest::from_json_str( + r#"{ + "version": 1, + "validator": { "local": "/builds/zebrad" }, + "indexer": { "local": "/builds/zainod" }, + "wallet": { "local": "/builds/zcash-devtool" } + }"#, + ) + .unwrap(); + assert!(!manifest.validator.is_container()); + } + + /// The template must always parse — it declares `runtime` + /// explicitly, so no docker/podman probe runs and the test is + /// hermetic on hosts without either installed. + #[test] + fn template_parses() { + let manifest = ArtifactManifest::from_json_str(ArtifactManifest::template_json()).unwrap(); + assert!(manifest.validator.is_container()); + assert!(matches!( + &manifest.indexer, + ArtifactSource::HostProcess { binary: Some(_) } + )); + } + + #[test] + fn default_manifest_is_all_host_defaults() { + let manifest = ArtifactManifest::default(); + for source in [&manifest.validator, &manifest.indexer, &manifest.wallet] { + assert!(matches!( + source, + ArtifactSource::HostProcess { binary: None } + )); + } + } +} diff --git a/zcash_local_net/src/container/preflight.rs b/zcash_local_net/src/container/preflight.rs new file mode 100644 index 0000000..f93a010 --- /dev/null +++ b/zcash_local_net/src/container/preflight.rs @@ -0,0 +1,393 @@ +//! Preflight: validate a manifest's artifacts against the current +//! environment *before* any launch. +//! +//! A failed container launch surfaces as a `LaunchError` carrying the +//! runtime client's stderr — accurate, but late and buried inside a +//! test failure. Preflight front-loads the same checks into precise, +//! human-readable diagnostics: run it once in test-suite setup (or via +//! `zcash-local-net preflight` before `cargo nextest run`) and every +//! environment problem — missing runtime, unreachable daemon, absent +//! image, unpullable reference, missing or non-executable binary — is +//! reported per artifact, all at once. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use super::manifest::ArtifactManifest; +use super::{ArtifactSource, ContainerImage, ContainerRuntime, PullPolicy}; + +/// One preflight check's outcome. +#[derive(Clone, Debug)] +pub struct PreflightCheck { + /// What was checked, e.g. `validator: image zfnd/zebra:v6.0.0`. + pub subject: String, + /// Whether the check passed. + pub passed: bool, + /// Evidence: where the artifact resolved to, or what exactly is + /// wrong and how to fix it. + pub detail: String, +} + +/// Every check preflight ran, in execution order. +#[derive(Clone, Debug)] +pub struct PreflightReport { + checks: Vec, +} + +impl PreflightReport { + /// Whether every check passed. + pub fn passed(&self) -> bool { + self.checks.iter().all(|check| check.passed) + } + + /// The individual check outcomes. + pub fn checks(&self) -> &[PreflightCheck] { + &self.checks + } +} + +impl std::fmt::Display for PreflightReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for check in &self.checks { + let status = if check.passed { "ok " } else { "FAIL" }; + writeln!(f, "[{status}] {}: {}", check.subject, check.detail)?; + } + Ok(()) + } +} + +impl ArtifactManifest { + /// Check every artifact of this manifest against the current + /// environment. Never fails early: the report carries one entry + /// per check so a broken environment is diagnosed in a single + /// pass. See the module docs for the check list. + pub async fn preflight(&self) -> PreflightReport { + let artifacts: [(&str, &'static str, &ArtifactSource); 3] = [ + ("validator", "zebrad", &self.validator), + ("indexer", "zainod", &self.indexer), + ("wallet", "zcash-devtool", &self.wallet), + ]; + + let mut checks = Vec::new(); + + // Runtime checks, once per distinct runtime in use. + let runtimes: BTreeSet<&'static str> = artifacts + .iter() + .filter_map(|(_, _, source)| match source { + ArtifactSource::Container(image) => Some(image.runtime.cli_name()), + ArtifactSource::HostProcess { .. } => None, + }) + .collect(); + for cli_name in runtimes { + let runtime = ContainerRuntime::from_manifest_name(cli_name) + .expect("cli_name round-trips through from_manifest_name"); + checks.push(check_runtime(runtime).await); + } + + for (artifact, executable_name, source) in artifacts { + match source { + ArtifactSource::HostProcess { binary } => { + checks.push(check_host_binary( + artifact, + executable_name, + binary.as_deref(), + )); + } + ArtifactSource::Container(image) => { + checks.push(check_image(artifact, image).await); + } + } + } + + PreflightReport { checks } + } +} + +/// Runtime client present (`--version`) and daemon reachable (`info`). +async fn check_runtime(runtime: ContainerRuntime) -> PreflightCheck { + let subject = format!("runtime: {runtime}"); + let version = tokio::process::Command::new(runtime.cli_name()) + .arg("--version") + .output() + .await; + let version = match version { + Ok(output) if output.status.success() => { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + Ok(output) => { + return PreflightCheck { + subject, + passed: false, + detail: format!( + "`{runtime} --version` exited {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ), + }; + } + Err(io_error) => { + return PreflightCheck { + subject, + passed: false, + detail: format!( + "`{runtime}` could not be spawned ({io_error}); install it or set \ + the manifest's `runtime` to an installed one" + ), + }; + } + }; + + // `info` exercises the daemon/socket, which `--version` does not. + match tokio::process::Command::new(runtime.cli_name()) + .arg("info") + .output() + .await + { + Ok(output) if output.status.success() => PreflightCheck { + subject, + passed: true, + detail: format!("{version}; daemon reachable"), + }, + Ok(output) => PreflightCheck { + subject, + passed: false, + detail: format!( + "client found ({version}) but `{runtime} info` exited {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ), + }, + Err(io_error) => PreflightCheck { + subject, + passed: false, + detail: format!("client found ({version}) but `{runtime} info` failed: {io_error}"), + }, + } +} + +/// Image present locally, pulled per policy when not. +async fn check_image(artifact: &str, image: &ContainerImage) -> PreflightCheck { + let subject = format!("{artifact}: image {}", image.image); + let cli = image.runtime.cli_name(); + + let present = tokio::process::Command::new(cli) + .args(["image", "inspect", "--format", "{{.Id}}", &image.image]) + .output() + .await + .is_ok_and(|output| output.status.success()); + + let must_pull = match (present, image.pull) { + (true, PullPolicy::Always) => true, + (true, _) => { + return PreflightCheck { + subject, + passed: true, + detail: "image present locally".to_string(), + }; + } + (false, PullPolicy::Never) => { + return PreflightCheck { + subject, + passed: false, + detail: format!( + "image not present locally and pull policy is \"never\"; \ + build or load it first (`{cli} pull {}` / `{cli} load`)", + image.image + ), + }; + } + (false, _) => true, + }; + debug_assert!(must_pull); + + match tokio::process::Command::new(cli) + .args(["pull", &image.image]) + .output() + .await + { + Ok(output) if output.status.success() => PreflightCheck { + subject, + passed: true, + detail: if present { + "image refreshed (pull policy \"always\")".to_string() + } else { + "image pulled".to_string() + }, + }, + Ok(output) => PreflightCheck { + subject, + passed: false, + detail: format!( + "`{cli} pull {}` exited {}: {}", + image.image, + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ), + }, + Err(io_error) => PreflightCheck { + subject, + passed: false, + detail: format!("`{cli} pull {}` failed to spawn: {io_error}", image.image), + }, + } +} + +/// Host binary resolvable and executable — explicit path, or the +/// `TEST_BINARIES_DIR` / `PATH` resolution the launch will perform. +fn check_host_binary( + artifact: &str, + executable_name: &'static str, + binary: Option<&Path>, +) -> PreflightCheck { + let subject = format!("{artifact}: host binary {executable_name}"); + match binary { + Some(path) => match executable_file_status(path) { + Ok(()) => PreflightCheck { + subject, + passed: true, + detail: format!("local build at {}", path.display()), + }, + Err(reason) => PreflightCheck { + subject, + passed: false, + detail: format!("{}: {reason}", path.display()), + }, + }, + None => match resolve_via_env(executable_name) { + Some((provenance, path)) => match executable_file_status(&path) { + Ok(()) => PreflightCheck { + subject, + passed: true, + detail: format!("resolved via {provenance} to {}", path.display()), + }, + Err(reason) => PreflightCheck { + subject, + passed: false, + detail: format!( + "resolved via {provenance} to {} but {reason}", + path.display() + ), + }, + }, + None => PreflightCheck { + subject, + passed: false, + detail: format!( + "not found in TEST_BINARIES_DIR or PATH; set TEST_BINARIES_DIR to a \ + directory containing {executable_name}, add it to PATH, or give this \ + artifact an explicit `local` path or an `image` in the manifest" + ), + }, + }, + } +} + +/// Mirror the launch-time lookup: `TEST_BINARIES_DIR` first (see +/// `crate::utils::executable_finder`), then a `PATH` walk. +fn resolve_via_env(executable_name: &str) -> Option<(&'static str, PathBuf)> { + if let Ok(directory) = std::env::var("TEST_BINARIES_DIR") { + let candidate = PathBuf::from(directory).join(executable_name); + if candidate.exists() { + return Some(("TEST_BINARIES_DIR", candidate)); + } + } + let path_var = std::env::var_os("PATH")?; + std::env::split_paths(&path_var) + .map(|dir| dir.join(executable_name)) + .find(|candidate| candidate.is_file()) + .map(|found| ("PATH", found)) +} + +/// `Ok(())` when `path` is an existing, executable regular file; +/// otherwise the human-readable reason it is not. +fn executable_file_status(path: &Path) -> Result<(), String> { + let metadata = match std::fs::metadata(path) { + Ok(metadata) => metadata, + Err(io_error) => return Err(format!("cannot stat ({io_error})")), + }; + if !metadata.is_file() { + return Err("exists but is not a regular file".to_string()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if metadata.permissions().mode() & 0o111 == 0 { + return Err("exists but is not executable (chmod +x?)".to_string()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + + fn manifest_with_wallet(source: ArtifactSource) -> ArtifactManifest { + ArtifactManifest { + // Point the other two artifacts at concrete files that + // pass, so each test observes exactly one interesting + // check. `std::env::current_exe` is a real executable + // regular file by construction. + validator: ArtifactSource::local_binary(std::env::current_exe().unwrap()), + indexer: ArtifactSource::local_binary(std::env::current_exe().unwrap()), + wallet: source, + } + } + + fn wallet_check(report: &PreflightReport) -> &PreflightCheck { + report + .checks() + .iter() + .find(|check| check.subject.starts_with("wallet:")) + .expect("wallet check present") + } + + #[tokio::test] + async fn missing_explicit_binary_fails_with_the_path() { + let manifest = manifest_with_wallet(ArtifactSource::local_binary( + "/nonexistent/zcash-devtool-build", + )); + let report = manifest.preflight().await; + let check = wallet_check(&report); + assert!(!check.passed); + assert!(check.detail.contains("/nonexistent/zcash-devtool-build")); + assert!(!report.passed()); + } + + #[cfg(unix)] + #[tokio::test] + async fn non_executable_binary_fails_with_remediation() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("zcash-devtool"); + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all(b"#!/bin/sh\n").unwrap(); + // Default create mode is 0o644: not executable. + let manifest = manifest_with_wallet(ArtifactSource::local_binary(&path)); + let report = manifest.preflight().await; + let check = wallet_check(&report); + assert!(!check.passed); + assert!(check.detail.contains("not executable"), "{}", check.detail); + } + + #[tokio::test] + async fn existing_executable_passes() { + let manifest = manifest_with_wallet(ArtifactSource::local_binary( + std::env::current_exe().unwrap(), + )); + let report = manifest.preflight().await; + assert!(report.passed(), "{report}"); + } + + /// The report renders one line per check with a stable pass/fail + /// tag — the CLI's output contract. + #[tokio::test] + async fn report_renders_one_line_per_check() { + let manifest = manifest_with_wallet(ArtifactSource::local_binary("/nonexistent")); + let report = manifest.preflight().await; + let rendered = report.to_string(); + assert_eq!(rendered.lines().count(), report.checks().len()); + assert!(rendered.contains("[ok ]")); + assert!(rendered.contains("[FAIL]")); + } +} diff --git a/zcash_local_net/src/indexer/zainod.rs b/zcash_local_net/src/indexer/zainod.rs index b35a659..ca69460 100644 --- a/zcash_local_net/src/indexer/zainod.rs +++ b/zcash_local_net/src/indexer/zainod.rs @@ -8,7 +8,6 @@ use zingo_consensus::NetworkKind; use crate::logs::LogsToDir; use crate::logs::LogsToStdoutAndStderr as _; -use crate::utils::executable_finder::trace_version_and_location; use crate::{ ProcessId, config, error::{IndexerSyncError, LaunchError}, @@ -16,7 +15,6 @@ use crate::{ launch, network::{self}, process::Process, - utils::executable_finder::pick_command, }; /// The stdout marker zainod prints for every block its chain index @@ -133,6 +131,11 @@ pub struct ZainodConfig { /// lifetime — the launch-time listener probe included. `None` /// (the default) is a passthrough front with no observer. pub grpc_front_observer: Option>, + /// Where the zainod binary comes from: host-process resolution + /// (the default), an explicit local build, or a container image. + /// See [`crate::container::ArtifactSource`]; typically seeded from + /// an [`crate::container::manifest::ArtifactManifest`]. + pub source: crate::container::ArtifactSource, } impl Default for ZainodConfig { @@ -143,6 +146,7 @@ impl Default for ZainodConfig { chain_cache: None, network: NetworkKind::Regtest, grpc_front_observer: None, + source: crate::container::ArtifactSource::default(), } } } @@ -160,8 +164,15 @@ impl IndexerConfig for ZainodConfig { /// This struct is used to represent and manage the Zainod process. #[derive(Debug)] pub struct Zainod { - /// Child process handle + /// Child process handle. In container mode this is the foreground + /// runtime client (`docker|podman run`), through which the + /// container's stdio streams. handle: Child, + /// The named container zainod runs as when the artifact source is + /// a container image; `None` in host-process mode. Held so `stop` + /// can force-remove the container — killing the client alone would + /// leave it running. + container: Option, /// gRPC front proxy — the canonical public endpoint of the gRPC /// listener, bound before the process started. grpc_front: crate::front::Front, @@ -291,15 +302,33 @@ impl Zainod { .unwrap(); let executable_name = "zainod"; - trace_version_and_location(executable_name, "--version"); - let mut command = pick_command(executable_name, false); + crate::container::trace_version(&config.source, executable_name, "--version"); + // In container mode, mount the config dir (and the caller's + // chain cache, when one is set) at identical paths so the + // config file just written is valid verbatim inside the + // container. The ephemeral cache dir is deliberately *not* + // mounted: its `TempDir` is deleted when this function + // returns even in host mode, and zainod (re)creates the + // configured path itself — inside the container's own + // filesystem, reaped with the container by `--rm`. + let container = config.source.new_instance(executable_name); + let mut mounts: Vec<&std::path::Path> = vec![config_dir.path()]; + if let Some(cache) = config.chain_cache.as_ref() { + mounts.push(cache.as_path()); + } + let mut command = config.source.command(&crate::container::LaunchSpec { + executable_name, + container_name: container.as_ref().map(|c| c.name()), + mounts: &mounts, + interactive: false, + }); command.args([ "start", "--config", config_file_path.to_str().expect("should be valid UTF-8"), ]); - let handle = launch::spawn_and_wait( + let spawned = launch::spawn_and_wait( ProcessId::Zainod, &mut command, &logs_dir, @@ -308,10 +337,23 @@ impl Zainod { &["Error:"], &[], ) - .await?; + .await; + let handle = match spawned { + Ok(handle) => handle, + Err(error) => { + // A failed container launch may leave the container + // running even though `launch::wait` gave up on it; + // don't leak it past the failed launch. + if let Some(container) = &container { + container.force_remove(); + } + return Err(error); + } + }; let mut zainod = Zainod { handle, + container, grpc_front, raw_grpc_listen_addr: std::net::SocketAddr::from(([127, 0, 0, 1], port)), logs_dir, @@ -397,6 +439,12 @@ impl Process for Zainod { } fn stop(&mut self) { + // Container mode: the handle is only the runtime client; + // killing it would orphan the container, so remove the + // container first (which also ends the client). + if let Some(container) = &self.container { + container.force_remove(); + } match self.handle.kill() { Ok(()) => {} // `kill` returns `InvalidInput` when the child has already diff --git a/zcash_local_net/src/lib.rs b/zcash_local_net/src/lib.rs index 05f84b9..6aa5b83 100644 --- a/zcash_local_net/src/lib.rs +++ b/zcash_local_net/src/lib.rs @@ -41,8 +41,33 @@ //! //! See [`crate::LocalNet`]. //! +//! ## Containers and the artifact manifest +//! +//! Every managed process can alternatively run from a **container +//! image** (via the `docker` or `podman` CLI) instead of a host +//! binary. Which artifacts come from where is described by an +//! [artifact manifest](crate::container::manifest::ArtifactManifest) +//! — a small JSON file consumers keep with their test setup — with a +//! `local` **escape hatch** for artifacts built locally from source. +//! The manifest doubles as a pre-check: validate it against the +//! current environment with +//! [`ArtifactManifest::preflight`](crate::container::manifest::ArtifactManifest::preflight), +//! or from a shell with the bundled `zcash-local-net preflight` CLI +//! before invoking the test runner: +//! +//! ```sh +//! zcash-local-net preflight --manifest ci-artifacts.json \ +//! && ZCASH_LOCAL_NET_MANIFEST=ci-artifacts.json cargo nextest run +//! ``` +//! +//! See [`crate::container`] for the mechanics (foreground containers +//! on the host network, harness dirs bind-mounted at identical +//! paths — all launch/readiness/proxy machinery is shared with host +//! processes). +//! pub mod config; +pub mod container; pub mod error; pub mod front; pub mod indexer; diff --git a/zcash_local_net/src/validator/zebrad.rs b/zcash_local_net/src/validator/zebrad.rs index 97de967..5e1076d 100644 --- a/zcash_local_net/src/validator/zebrad.rs +++ b/zcash_local_net/src/validator/zebrad.rs @@ -6,7 +6,6 @@ use crate::{ launch, logs::{LogsToDir, LogsToStdoutAndStderr as _}, process::Process, - utils::executable_finder::{pick_command, trace_version_and_location}, validator::{Validator, ValidatorConfig}, }; use zingo_consensus::{ActivationHeights, MinerPool, NetworkType}; @@ -21,8 +20,10 @@ use tempfile::TempDir; /// Zebrad configuration /// -/// Use `zebrad_bin` to specify the binary location. -/// If the binary is in $PATH, `None` can be specified to run "zebrad". +/// Use `source` to say where the zebrad binary comes from: the default +/// resolves via `TEST_BINARIES_DIR` / `PATH`, and the alternatives are +/// an explicit local-build path or a container image (see +/// [`crate::container::ArtifactSource`]). /// /// Each `*_listen_port` field pins the corresponding **raw** zebrad /// listener when `Some(N)`; `None` (the default) lets zebrad bind port @@ -89,6 +90,11 @@ pub struct ZebradConfig { /// probes and the regtest launch-mine. `None` (the default) is a /// passthrough front with no observer. pub rpc_front_observer: Option>, + /// Where the zebrad binary comes from: host-process resolution + /// (the default), an explicit local build, or a container image. + /// See [`crate::container::ArtifactSource`]; typically seeded from + /// an [`crate::container::manifest::ArtifactManifest`]. + pub source: crate::container::ArtifactSource, } impl Default for ZebradConfig { @@ -113,6 +119,7 @@ impl Default for ZebradConfig { ), min_connected_peers: 0, rpc_front_observer: None, + source: crate::container::ArtifactSource::default(), } } } @@ -154,8 +161,15 @@ impl ValidatorConfig for ZebradConfig { /// This struct is used to represent and manage the Zebrad process. #[derive(Debug)] pub struct Zebrad { - /// Child process handle + /// Child process handle. In container mode this is the foreground + /// runtime client (`docker|podman run`), through which the + /// container's stdio streams. handle: Child, + /// The named container zebrad runs as when the artifact source is + /// a container image; `None` in host-process mode. Held so `stop` + /// can force-remove the container — killing the client alone would + /// leave it running. + container: Option, /// JSON-RPC front proxy — the canonical public endpoint of the /// JSON-RPC listener, bound before the process started. rpc_front: crate::front::Front, @@ -461,15 +475,29 @@ impl Zebrad { .unwrap(); let executable_name = "zebrad"; - trace_version_and_location(executable_name, "--version"); - let mut command = pick_command(executable_name, false); + crate::container::trace_version(&config.source, executable_name, "--version"); + // In container mode, mount the harness dirs at identical paths + // so the config file just written is valid verbatim inside the + // container (see `crate::container`). The chain cache is + // mounted too so cached-chain launches can read it. + let container = config.source.new_instance(executable_name); + let mut mounts: Vec<&std::path::Path> = vec![config_dir.path(), data_dir.path()]; + if let Some(cache) = config.chain_cache.as_ref() { + mounts.push(cache.as_path()); + } + let mut command = config.source.command(&crate::container::LaunchSpec { + executable_name, + container_name: container.as_ref().map(|c| c.name()), + mounts: &mounts, + interactive: false, + }); command.args([ "--config", config_file_path.to_str().expect("should be valid UTF-8"), "start", ]); - let mut handle = launch::spawn_and_wait( + let spawned = launch::spawn_and_wait( ProcessId::Zebrad, &mut command, &logs_dir, @@ -507,7 +535,20 @@ impl Zebrad { "warning: some trace filter directives would enable traces that are disabled statically", ], ) - .await?; + .await; + let mut handle = match spawned { + Ok(handle) => handle, + Err(error) => { + // A failed container launch may leave the container + // running even though `launch::wait` gave up on it + // (e.g. an error indicator matched while the process + // survives); don't leak it past the failed launch. + if let Some(container) = &container { + container.force_remove(); + } + return Err(error); + } + }; // Discover where zebrad actually bound each listener. With // unpinned (port 0) listeners the launch log is the only place @@ -519,6 +560,9 @@ impl Zebrad { // endpoint is not an exited process); don't leak it // past the failed launch. A kill error only means the // child already exited, which is the state kill wants. + if let Some(container) = &container { + container.force_remove(); + } let _ = handle.kill(); return Err(error); } @@ -547,6 +591,7 @@ impl Zebrad { let zebrad = Zebrad { handle, + container, rpc_front, raw_listen_addrs, config_dir, @@ -631,7 +676,20 @@ impl Process for Zebrad { } fn stop(&mut self) { - self.handle.kill().expect("zebrad couldn't be killed"); + // Container mode: the handle is only the runtime client; + // killing it would orphan the container, so remove the + // container first (which also ends the client). + if let Some(container) = &self.container { + container.force_remove(); + } + match self.handle.kill() { + Ok(()) => {} + // `kill` returns `InvalidInput` when the child has already + // exited and been reaped — e.g. the runtime client after + // its container was just force-removed. + Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {} + Err(e) => panic!("zebrad couldn't be killed: {e}"), + } } fn print_all(&self) { diff --git a/zcash_local_net/src/wallet/zcash_devtool.rs b/zcash_local_net/src/wallet/zcash_devtool.rs index b8d7ec7..a579a13 100644 --- a/zcash_local_net/src/wallet/zcash_devtool.rs +++ b/zcash_local_net/src/wallet/zcash_devtool.rs @@ -25,7 +25,6 @@ use crate::{ error::WalletError, indexer::Indexer, logs::LogsToDir, - utils::executable_finder::pick_command, wallet::{ AddressReceiver, GetInfo, ValidatorHeights, Wallet, WalletBalance, WalletConfig, WalletNetwork, @@ -117,6 +116,14 @@ pub struct ZcashDevtoolConfig { /// policy (3 trusted / 10 untrusted confirmations) makes nothing /// spendable on a shallow regtest chain. pub min_confirmations: std::num::NonZeroU32, + /// Where the zcash-devtool binary comes from: host-process + /// resolution (the default), an explicit local build, or a + /// container image — in which case **every wallet operation runs + /// as its own one-shot container** (the wallet has no resident + /// process to containerize). See + /// [`crate::container::ArtifactSource`]; typically seeded from an + /// [`crate::container::manifest::ArtifactManifest::wallet_source`]. + pub source: crate::container::ArtifactSource, } impl ZcashDevtoolConfig { @@ -137,6 +144,7 @@ impl ZcashDevtoolConfig { indexer_port: 0, network, min_confirmations: std::num::NonZeroU32::MIN, + source: crate::container::ArtifactSource::default(), } } @@ -158,6 +166,7 @@ impl ZcashDevtoolConfig { indexer_port: 0, network, min_confirmations: std::num::NonZeroU32::MIN, + source: crate::container::ArtifactSource::default(), } } } @@ -307,7 +316,15 @@ impl ZcashDevtool { args: &[&str], stdin_line: Option<&str>, ) -> Result { - let mut command = pick_command(EXECUTABLE_NAME, false); + // The wallet dir is bind-mounted at an identical path in + // container mode, so `-w` and the identity/heights paths under + // it are valid verbatim inside the one-shot container. + let mut command = self.config.source.command(&crate::container::LaunchSpec { + executable_name: EXECUTABLE_NAME, + container_name: None, + mounts: &[self.wallet_dir.path()], + interactive: stdin_line.is_some(), + }); command .arg("wallet") .arg("-w") @@ -372,7 +389,7 @@ impl Wallet for ZcashDevtool { type Config = ZcashDevtoolConfig; async fn launch(config: Self::Config) -> Result { - crate::utils::executable_finder::trace_version_and_location(EXECUTABLE_NAME, "--help"); + crate::container::trace_version(&config.source, EXECUTABLE_NAME, "--help"); // tempfile failures here are environment errors (no tmpfs // space/permissions), same unwrap policy as the daemon structs. diff --git a/zcash_local_net/tests/containers.rs b/zcash_local_net/tests/containers.rs new file mode 100644 index 0000000..13f0c56 --- /dev/null +++ b/zcash_local_net/tests/containers.rs @@ -0,0 +1,50 @@ +//! Live tests for the containerized artifact path. +//! +//! These launch the real stack from whatever a manifest names — +//! container images and/or local builds — so they are `#[ignore]`d by +//! default: they need a container runtime plus real zebrad/zainod +//! artifacts, which plain `cargo test` environments don't guarantee. +//! Run them explicitly once the environment provides both: +//! +//! ```sh +//! zcash-local-net preflight --manifest ci-artifacts.json +//! ZCASH_LOCAL_NET_MANIFEST=ci-artifacts.json \ +//! cargo test -p zcash_local_net --test containers -- --ignored +//! ``` +//! +//! The container *plumbing* itself (foreground `run` semantics, host +//! networking, identical-path mounts, readiness scanning on container +//! logs, front wiring, `rm --force` teardown) is exercised without any +//! zcash artifacts by the unit tests in `src/container.rs` and was +//! validated live against a scratch image; this file pins the missing +//! piece — the real images' behavior — wherever a manifest provides +//! them. + +use zcash_local_net::container::manifest::ArtifactManifest; + +/// The consumer flow, end to end: manifest → preflight (the pre-check) +/// → LocalNet launch → mine to Indexer convergence. Artifact sources +/// come from `ZCASH_LOCAL_NET_MANIFEST`, so one invocation covers +/// all-images, all-local-builds, or any mix (the escape hatch). +#[ignore = "requires ZCASH_LOCAL_NET_MANIFEST plus the artifacts (images or binaries) it names"] +#[tokio::test(flavor = "multi_thread")] +async fn manifest_local_net_launches_and_converges() { + tracing_subscriber::fmt().init(); + + let manifest = ArtifactManifest::from_env() + .expect("manifest should load") + .expect("set ZCASH_LOCAL_NET_MANIFEST to run this test"); + + let report = manifest.preflight().await; + assert!(report.passed(), "artifact preflight failed:\n{report}"); + + let local_net = manifest + .launch_local_net() + .await + .expect("LocalNet should launch from the manifest's artifacts"); + + local_net + .generate_blocks_converged(2) + .await + .expect("mined blocks should reach the Indexer's chain index"); +} From dd319c748d59929d3d3b5b91b772a6874d0b2325 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 00:26:12 +0000 Subject: [PATCH 2/6] feat(zcash_local_net): require pinned image references in the artifact manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container artifacts exist to make test runs reproducible; a floating reference (`zebra`, `zebra:latest`) silently changes what a test run means when the registry moves the tag. Manifest loading now rejects untagged, empty-tagged and `latest`-tagged image references with the new `ManifestError::UnpinnedImage`, naming the offending reference and the fix (digest pin `repo@sha256:…`, the strongest form, or an explicit non-`latest` tag). A registry port is not mistaken for a tag. Preflight's image checks now also report the immutable identity each reference resolved to (first repo digest, falling back to the local image ID for locally built images) — the line to compare across machines when a manifest pins by tag. Direct `ContainerImage` construction bypasses the manifest check by design; its docs now state that programmatic callers carry the pinning responsibility themselves. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015dRfXaAyhrUC5LwgEGHaNi --- zcash_local_net/CHANGELOG.md | 6 ++ zcash_local_net/src/container.rs | 8 +- zcash_local_net/src/container/manifest.rs | 118 ++++++++++++++++++++- zcash_local_net/src/container/preflight.rs | 68 ++++++++++-- 4 files changed, 184 insertions(+), 16 deletions(-) diff --git a/zcash_local_net/CHANGELOG.md b/zcash_local_net/CHANGELOG.md index f29d645..5182b84 100644 --- a/zcash_local_net/CHANGELOG.md +++ b/zcash_local_net/CHANGELOG.md @@ -33,6 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `entrypoint` and `pull` policy `never`/`if-missing`/`always`) or a `local` path to a locally built binary (the **escape hatch**; also available programmatically as `ArtifactSource::local_binary`). + Image references name each binary's actual published image, one per + artifact, and **must be pinned**: digest-pinned (`repo@sha256:…`) + or carrying an explicit non-`latest` tag. Untagged and `:latest` + references are rejected at load time + (`ManifestError::UnpinnedImage`), and preflight reports the digest + each reference resolved to as reproducibility evidence. `ArtifactManifest::from_env()` loads the file named by `ZCASH_LOCAL_NET_MANIFEST`; `launch_local_net()` launches the core stack from it; `zebrad_config()` / `zainod_config()` / diff --git a/zcash_local_net/src/container.rs b/zcash_local_net/src/container.rs index 8eb3b1e..b844278 100644 --- a/zcash_local_net/src/container.rs +++ b/zcash_local_net/src/container.rs @@ -152,7 +152,13 @@ pub struct ContainerImage { /// The runtime CLI that runs this image. pub runtime: ContainerRuntime, /// Image reference (`repository[:tag][@digest]`), passed verbatim - /// to the runtime. + /// to the runtime — the binary's *actual published image* (e.g. + /// Zebra's official `zfnd/zebra`). Pin it: prefer a digest + /// (`repo@sha256:…`), or at least an explicit non-`latest` tag. + /// Manifest loading enforces this + /// ([`manifest::ManifestError::UnpinnedImage`]); constructing this + /// struct directly bypasses that check, so programmatic callers + /// carry the reproducibility responsibility themselves. pub image: String, /// Entrypoint override. `None` uses the artifact's executable name /// (`zebrad`, `zainod`, `zcash-devtool`), which deliberately diff --git a/zcash_local_net/src/container/manifest.rs b/zcash_local_net/src/container/manifest.rs index 02e3bfc..e1be5e3 100644 --- a/zcash_local_net/src/container/manifest.rs +++ b/zcash_local_net/src/container/manifest.rs @@ -24,10 +24,17 @@ //! //! Per artifact (`validator`, `indexer`, `wallet`): //! -//! - `image`: run from this container image reference. Optional -//! companions: `entrypoint` (defaults to the artifact's binary -//! name) and `pull` (`"never"` / `"if-missing"` / `"always"`, -//! default `"if-missing"`). +//! - `image`: run from this container image reference — the binary's +//! *actual published image* (e.g. Zebra's official `zfnd/zebra`), +//! one image per artifact. The reference **must be pinned**: either +//! digest-pinned (`repo@sha256:…`, the strongest form — immune to +//! tag reassignment) or carrying an explicit non-`latest` tag. +//! Untagged and `:latest` references are rejected at load time, +//! because a floating reference silently changes what a test run +//! means when the registry moves the tag. Optional companions: +//! `entrypoint` (defaults to the artifact's binary name) and `pull` +//! (`"never"` / `"if-missing"` / `"always"`, default +//! `"if-missing"`). //! - `local`: run this host binary directly — the escape hatch for //! locally built artifacts. Mutually exclusive with `image`. //! - `{}` or omitted: resolve the binary via `TEST_BINARIES_DIR` / @@ -93,6 +100,22 @@ pub enum ManifestError { /// What is wrong with it. reason: String, }, + /// An `image` reference is not pinned (no tag/digest, or the + /// floating `latest` tag). Pinning is mandatory: container + /// artifacts exist to make test runs reproducible, and a floating + /// reference silently changes meaning when the registry moves it. + #[error( + "manifest artifact `{artifact}`: image {image:?} is not pinned ({reason}); \ + pin it with a digest (`repo@sha256:…`) or an explicit non-`latest` tag" + )] + UnpinnedImage { + /// Which artifact table (`validator` / `indexer` / `wallet`). + artifact: &'static str, + /// The offending image reference. + image: String, + /// What makes the reference floating. + reason: &'static str, + }, /// `runtime` names something other than `docker` or `podman`. #[error("unknown container runtime {name:?} (expected \"docker\" or \"podman\")")] UnknownRuntime { @@ -309,6 +332,13 @@ fn resolve_artifact( Ok(ArtifactSource::HostProcess { binary: local }) } (Some(image), None) => { + if let Err(reason) = require_pinned(&image) { + return Err(ManifestError::UnpinnedImage { + artifact, + image, + reason, + }); + } let pull = raw .pull .as_deref() @@ -336,6 +366,26 @@ fn resolve_artifact( } } +/// `Ok(())` when `image` is a pinned reference: digest-pinned +/// (`…@sha256:…`) or carrying an explicit tag other than `latest`. +/// `Err` names what makes it floating. +/// +/// The tag is looked for after the last `/`, so a registry port +/// (`registry:5000/zebra`) is not mistaken for a tag. +fn require_pinned(image: &str) -> Result<(), &'static str> { + if image.contains('@') { + // Digest-pinned: immutable by construction. + return Ok(()); + } + let last_component = image.rsplit('/').next().unwrap_or(image); + match last_component.split_once(':') { + None => Err("it has no tag or digest"), + Some((_, "latest")) => Err("`latest` is a floating tag"), + Some((_, "")) => Err("its tag is empty"), + Some((_, _tag)) => Ok(()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -467,7 +517,7 @@ mod tests { r#"{ "version": 1, "runtime": "docker", - "validator": { "image": "zebra", "pull": "sometimes" } + "validator": { "image": "zebra:v1", "pull": "sometimes" } }"#, ) .unwrap_err(); @@ -480,6 +530,64 @@ mod tests { )); } + /// Unpinned image references must be rejected at load time, with + /// the offending reference and the reason in the error. + #[test] + fn unpinned_image_references_are_rejected() { + for (image, expected_reason_fragment) in [ + ("zebra", "no tag or digest"), + ("zfnd/zebra:latest", "floating tag"), + ("zfnd/zebra:", "tag is empty"), + // A registry port is not a tag. + ("registry:5000/zebra", "no tag or digest"), + ] { + let error = ArtifactManifest::from_json_str(&format!( + r#"{{ + "version": 1, + "runtime": "docker", + "validator": {{ "image": "{image}" }} + }}"#, + )) + .unwrap_err(); + let ManifestError::UnpinnedImage { + artifact: "validator", + image: reported, + reason, + } = &error + else { + panic!("expected UnpinnedImage for {image:?}, got {error:?}"); + }; + assert_eq!(reported, image); + assert!( + reason.contains(expected_reason_fragment), + "{image:?}: reason {reason:?} should mention {expected_reason_fragment:?}" + ); + } + } + + /// Pinned references — explicit tags and digests, with or without + /// a ported registry — must pass. + #[test] + fn pinned_image_references_are_accepted() { + for image in [ + "zfnd/zebra:v6.0.0", + "registry:5000/zebra:v6.0.0", + "zfnd/zebra@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "zfnd/zebra:v6.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "zln-test-daemon:local", + ] { + let manifest = ArtifactManifest::from_json_str(&format!( + r#"{{ + "version": 1, + "runtime": "docker", + "validator": {{ "image": "{image}" }} + }}"#, + )) + .unwrap_or_else(|error| panic!("{image:?} should be accepted, got {error:?}")); + assert!(manifest.validator.is_container()); + } + } + /// A manifest with no `image` artifacts must never probe for a /// container runtime — the manifest (and `Default`) must work on /// hosts with neither docker nor podman installed. Indirectly diff --git a/zcash_local_net/src/container/preflight.rs b/zcash_local_net/src/container/preflight.rs index f93a010..49631e7 100644 --- a/zcash_local_net/src/container/preflight.rs +++ b/zcash_local_net/src/container/preflight.rs @@ -182,7 +182,10 @@ async fn check_image(artifact: &str, image: &ContainerImage) -> PreflightCheck { return PreflightCheck { subject, passed: true, - detail: "image present locally".to_string(), + detail: format!( + "image present locally ({})", + image_identity(cli, &image.image).await + ), }; } (false, PullPolicy::Never) => { @@ -205,15 +208,18 @@ async fn check_image(artifact: &str, image: &ContainerImage) -> PreflightCheck { .output() .await { - Ok(output) if output.status.success() => PreflightCheck { - subject, - passed: true, - detail: if present { - "image refreshed (pull policy \"always\")".to_string() - } else { - "image pulled".to_string() - }, - }, + Ok(output) if output.status.success() => { + let identity = image_identity(cli, &image.image).await; + PreflightCheck { + subject, + passed: true, + detail: if present { + format!("image refreshed (pull policy \"always\"; {identity})") + } else { + format!("image pulled ({identity})") + }, + } + } Ok(output) => PreflightCheck { subject, passed: false, @@ -232,6 +238,48 @@ async fn check_image(artifact: &str, image: &ContainerImage) -> PreflightCheck { } } +/// The image's immutable identity, as reproducibility evidence in the +/// report: the first registry digest (`repo@sha256:…`) when the image +/// came from a registry, else the local image ID (locally built +/// images have no repo digest). Manifests must already *pin* their +/// references; this states what the pin resolved to on this machine — +/// for a tag-pinned reference, the line to compare across machines. +async fn image_identity(cli: &str, image: &str) -> String { + // `{{json .RepoDigests}}` rather than `{{index …}}`: the json + // helper exists in both docker's and podman's template engines and + // does not error on an empty list. + let repo_digest = tokio::process::Command::new(cli) + .args([ + "image", + "inspect", + "--format", + "{{json .RepoDigests}}", + image, + ]) + .output() + .await + .ok() + .filter(|output| output.status.success()) + .and_then(|output| serde_json::from_slice::>(&output.stdout).ok()) + .and_then(|digests| digests.into_iter().next()); + if let Some(digest) = repo_digest { + return digest; + } + tokio::process::Command::new(cli) + .args(["image", "inspect", "--format", "{{.Id}}", image]) + .output() + .await + .ok() + .filter(|output| output.status.success()) + .map(|output| { + format!( + "local image id {}", + String::from_utf8_lossy(&output.stdout).trim() + ) + }) + .unwrap_or_else(|| "identity unavailable".to_string()) +} + /// Host binary resolvable and executable — explicit path, or the /// `TEST_BINARIES_DIR` / `PATH` resolution the launch will perform. fn check_host_binary( From 2684a39a5f12c5fabc758c5227587068608a521c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 00:37:10 +0000 Subject: [PATCH 3/6] feat(zcash_local_net): track preferences and explicit pin bumping for the artifact manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest now doubles as intent and lockfile, Cargo.toml/Cargo.lock style in one file: each image artifact may declare `track` — the floating reference the consumer prefers to follow (`…:latest`, a release-channel tag) — while `image` remains the pin every run uses. Run-time code never consults the registry about what a tag currently means; moving a pin is a deliberate act: zcash-local-net update [--manifest ] [validator|indexer|wallet …] resolves each selected artifact's tracked reference (its `track` field, or the tag its `image` was pinned from) against the registry — pull, then read the repo digest — and rewrites `image` to the digest-pinned result (`repo:tag@sha256:…`, tag preserved for readability, digest authoritative), reporting every bump. The file is rewritten in the manifest's canonical formatting (the serde schema now also serializes, in declaration order), so each bump is a reviewable version-control diff. The library entry point is `container::update::update_manifest_file`. Semantics pinned by tests: a `track`-only artifact is deliberately not launchable until its first update mints the pin (new `ManifestError::UnresolvedTrack`, whose message says to run update); digest-only images without `track` have no floating preference and are never touched; `track` is rejected on `local` artifacts and must itself be floating (a digest-pinned `track` has nothing to follow); a resolver failure surfaces before the file is touched; the bumped manifest is validated before it replaces the file. The registry interaction is injected behind a resolver seam, so the update logic is unit-tested hermetically; the CLI error paths (unreachable registry, track-only preflight, unknown artifact filter) were validated live against a real docker daemon. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015dRfXaAyhrUC5LwgEGHaNi --- zcash_local_net/CHANGELOG.md | 15 + zcash_local_net/src/bin/zcash-local-net.rs | 106 ++++- zcash_local_net/src/container.rs | 1 + zcash_local_net/src/container/manifest.rs | 117 +++++- zcash_local_net/src/container/update.rs | 462 +++++++++++++++++++++ 5 files changed, 684 insertions(+), 17 deletions(-) create mode 100644 zcash_local_net/src/container/update.rs diff --git a/zcash_local_net/CHANGELOG.md b/zcash_local_net/CHANGELOG.md index 5182b84..bdb61d8 100644 --- a/zcash_local_net/CHANGELOG.md +++ b/zcash_local_net/CHANGELOG.md @@ -43,6 +43,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ZCASH_LOCAL_NET_MANIFEST`; `launch_local_net()` launches the core stack from it; `zebrad_config()` / `zainod_config()` / `wallet_source()` seed individual configs for customized launches. +- **Explicit pin bumping** (`container::update`, and `zcash-local-net + update [--manifest ] [artifact…]`): the manifest doubles as + intent and lockfile. Each image artifact may declare a `track` + field — the floating reference the consumer prefers to follow + (`…:latest`, a release channel tag) — while `image` stays the pin + every run uses. Pins never move at run time; the update command is + the only thing that moves them: it resolves each tracked reference + (the `track` field, or the tag `image` was pinned from) against the + registry and rewrites `image` to the digest-pinned result + (`repo:tag@sha256:…`) in canonical formatting, reporting every + bump — a reviewable version-control diff, exactly like a lockfile + update. A `track`-only artifact is deliberately not launchable + until its first update mints the pin + (`ManifestError::UnresolvedTrack`); digest-only images without + `track` have no floating preference and are never touched. - **Preflight pre-check** (`ArtifactManifest::preflight`): validates a manifest against the current environment before any launch — runtime client present and daemon reachable, images present locally (pulled diff --git a/zcash_local_net/src/bin/zcash-local-net.rs b/zcash_local_net/src/bin/zcash-local-net.rs index 51db32e..977bd47 100644 --- a/zcash_local_net/src/bin/zcash-local-net.rs +++ b/zcash_local_net/src/bin/zcash-local-net.rs @@ -26,6 +26,7 @@ zcash-local-net — artifact pre-checks for the zcash_local_net harness USAGE: zcash-local-net preflight [--manifest ] + zcash-local-net update [--manifest ] [validator|indexer|wallet ...] zcash-local-net template zcash-local-net help @@ -35,21 +36,31 @@ COMMANDS: present (pulled per their pull policy), host binaries resolvable and executable. Exits 0 when every check passes, 1 otherwise. + update Explicitly bump the manifest's image pins: resolve each + artifact's tracked reference (its `track` field, or the + tag its `image` was pinned from) against the registry + and rewrite `image` to the digest-pinned result + (`repo:tag@sha256:…`). Pins never move at run time — + this command is the only thing that moves them. Name + artifacts to bump a subset. The file is rewritten in + canonical formatting; review the diff and commit it. template Print an example manifest (JSON) to stdout. help Print this message. -MANIFEST RESOLUTION (for `preflight`): +MANIFEST RESOLUTION: 1. --manifest 2. $ZCASH_LOCAL_NET_MANIFEST 3. ./zcash-local-net.json, if it exists - 4. none — the built-in default (every artifact resolved as a host - process via TEST_BINARIES_DIR / PATH) + 4. none — for `preflight`, the built-in default (every artifact + resolved as a host process via TEST_BINARIES_DIR / PATH); + `update` needs a file to rewrite and exits with an error. "; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { Some("preflight") => preflight(&args[1..]), + Some("update") => update(&args[1..]), Some("template") => { print!("{}", ArtifactManifest::template_json()); ExitCode::SUCCESS @@ -104,6 +115,74 @@ fn preflight(args: &[String]) -> ExitCode { } } +fn update(args: &[String]) -> ExitCode { + // `update` shares the --manifest flag but additionally accepts + // artifact names. + let mut manifest_flag: Option = None; + let mut artifacts: Vec<&str> = Vec::new(); + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + if arg == "--manifest" { + match rest.next() { + Some(path) => manifest_flag = Some(PathBuf::from(path)), + None => { + eprintln!("--manifest requires a path\n\n{USAGE}"); + return ExitCode::from(2); + } + } + } else { + artifacts.push(arg.as_str()); + } + } + + let (path, provenance) = match resolve_manifest_path(manifest_flag) { + Some(resolved) => resolved, + None => { + eprintln!( + "error: no manifest file to update — pass --manifest, set \ + ${MANIFEST_ENV_VAR}, or create ./{DEFAULT_MANIFEST_FILENAME}" + ); + return ExitCode::FAILURE; + } + }; + println!("manifest: {provenance}"); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("the update runtime should build"); + match runtime.block_on(zcash_local_net::container::update::update_manifest_file( + &path, &artifacts, + )) { + Ok(bumps) if bumps.is_empty() => { + println!("no artifact has a floating reference to follow; nothing to update"); + ExitCode::SUCCESS + } + Ok(bumps) => { + for bump in &bumps { + if bump.changed() { + let previous = if bump.previous.is_empty() { + "" + } else { + &bump.previous + }; + println!( + "{}: {} resolved; {previous} -> {}", + bump.artifact, bump.tracked, bump.pinned + ); + } else { + println!("{}: {} already current", bump.artifact, bump.tracked); + } + } + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + /// Parse `[--manifest ]`, rejecting anything else. fn parse_manifest_flag(args: &[String]) -> Result, String> { match args { @@ -114,6 +193,27 @@ fn parse_manifest_flag(args: &[String]) -> Result, String> { } } +/// The documented resolution order, for commands that need the file +/// itself rather than a loaded manifest. `None` when nothing resolves. +fn resolve_manifest_path(explicit: Option) -> Option<(PathBuf, String)> { + if let Some(path) = explicit { + let provenance = format!("{} (--manifest)", path.display()); + return Some((path, provenance)); + } + if let Some(path) = std::env::var_os(MANIFEST_ENV_VAR) { + let path = PathBuf::from(path); + let provenance = format!("{} (${MANIFEST_ENV_VAR})", path.display()); + return Some((path, provenance)); + } + let cwd_manifest = Path::new(DEFAULT_MANIFEST_FILENAME); + cwd_manifest.exists().then(|| { + ( + cwd_manifest.to_path_buf(), + format!("./{DEFAULT_MANIFEST_FILENAME} (working directory)"), + ) + }) +} + /// Apply the documented manifest-resolution order, reporting which /// source won so the user knows what was actually checked. fn resolve_manifest( diff --git a/zcash_local_net/src/container.rs b/zcash_local_net/src/container.rs index b844278..eca85a3 100644 --- a/zcash_local_net/src/container.rs +++ b/zcash_local_net/src/container.rs @@ -52,6 +52,7 @@ use crate::utils::executable_finder::pick_command; pub mod manifest; pub mod preflight; +pub mod update; /// The container runtime CLI used to run [`ArtifactSource::Container`] /// artifacts. diff --git a/zcash_local_net/src/container/manifest.rs b/zcash_local_net/src/container/manifest.rs index e1be5e3..6e31b70 100644 --- a/zcash_local_net/src/container/manifest.rs +++ b/zcash_local_net/src/container/manifest.rs @@ -16,12 +16,38 @@ //! { //! "version": 1, //! "runtime": "docker", -//! "validator": { "image": "zfnd/zebra:v6.0.0" }, +//! "validator": { +//! "image": "zfnd/zebra:v6.0.0", +//! "track": "zfnd/zebra:latest" +//! }, //! "indexer": { "local": "/home/dev/zaino/target/release/zainod" }, //! "wallet": {} //! } //! ``` //! +//! ## Intent vs pin: bumping references explicitly +//! +//! The manifest plays both the role of *intent* and of *lockfile* +//! (think `Cargo.toml` and `Cargo.lock` in one file): `track` records +//! the floating reference a consumer prefers to follow (`…:latest`, a +//! release-channel tag, …), while `image` records the pinned +//! reference every run actually uses. Nothing at run time ever +//! consults the registry for "what does the tag mean now" — moving +//! the pin is a deliberate act: +//! +//! ```sh +//! zcash-local-net update --manifest ci-artifacts.json +//! ``` +//! +//! resolves each artifact's tracked reference against the registry, +//! rewrites `image` to the digest-pinned result +//! (`repo:tag@sha256:…`), and reports every bump — leaving a +//! reviewable diff in version control. Artifacts without `track` are +//! bumped by re-resolving their `image` tag; digest-only images +//! without `track` are left untouched (there is no floating +//! preference to follow). See [`crate::container::update`] for the +//! library entry point. +//! //! Per artifact (`validator`, `indexer`, `wallet`): //! //! - `image`: run from this container image reference — the binary's @@ -100,6 +126,19 @@ pub enum ManifestError { /// What is wrong with it. reason: String, }, + /// An artifact has a `track` preference but no pinned `image` yet. + /// The manifest cannot be launched from until the pin is minted — + /// deliberately, by running the update command. + #[error( + "manifest artifact `{artifact}` tracks {track:?} but has no pinned `image` yet; \ + run `zcash-local-net update` to resolve the tracked reference and write the pin" + )] + UnresolvedTrack { + /// Which artifact table (`validator` / `indexer` / `wallet`). + artifact: &'static str, + /// The floating reference the artifact tracks. + track: String, + }, /// An `image` reference is not pinned (no tag/digest, or the /// floating `latest` tag). Pinning is mandatory: container /// artifacts exist to make test runs reproducible, and a floating @@ -134,24 +173,38 @@ pub enum ManifestError { /// Serde-facing manifest schema. Unknown fields are rejected so a /// typo'd key fails at load time instead of silently reverting an /// artifact to its default source. -#[derive(Debug, Deserialize)] +/// +/// Also `Serialize`, because `zcash-local-net update` rewrites the +/// manifest file in place; struct fields serialize in declaration +/// order, so this doubles as the manifest's canonical field order. +#[derive(Clone, Debug, Deserialize, serde::Serialize)] #[serde(deny_unknown_fields)] -struct RawManifest { - version: u32, - runtime: Option, - validator: Option, - indexer: Option, - wallet: Option, +pub(crate) struct RawManifest { + pub(crate) version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) runtime: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) validator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) indexer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) wallet: Option, } /// Serde-facing artifact entry. -#[derive(Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize, serde::Serialize)] #[serde(deny_unknown_fields)] -struct RawArtifact { - image: Option, - local: Option, - entrypoint: Option, - pull: Option, +pub(crate) struct RawArtifact { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) track: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) local: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) entrypoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) pull: Option, } /// A validated artifact manifest: one resolved [`ArtifactSource`] per @@ -208,6 +261,14 @@ impl ArtifactManifest { } fn parse(json: &str, path: Option<&Path>) -> Result { + Self::from_raw(Self::parse_raw(json, path)?) + } + + /// Parse the serde-facing schema and check the version, without + /// resolving artifacts. `zcash-local-net update` operates on this + /// representation: a manifest whose `track`-only artifacts are not + /// yet launchable must still be readable for updating. + pub(crate) fn parse_raw(json: &str, path: Option<&Path>) -> Result { let raw: RawManifest = serde_json::from_str(json).map_err(|error| ManifestError::Parse { path: path.map(Path::to_path_buf), @@ -216,7 +277,11 @@ impl ArtifactManifest { if raw.version != SUPPORTED_VERSION { return Err(ManifestError::UnsupportedVersion { found: raw.version }); } + Ok(raw) + } + /// Validate the raw schema and resolve every artifact source. + pub(crate) fn from_raw(raw: RawManifest) -> Result { let declared_runtime = raw .runtime .as_deref() @@ -298,6 +363,7 @@ impl ArtifactManifest { "runtime": "docker", "validator": { "image": "zfnd/zebra:v6.0.0", + "track": "zfnd/zebra:latest", "entrypoint": "zebrad", "pull": "if-missing" }, @@ -317,6 +383,29 @@ fn resolve_artifact( runtime: Option, ) -> Result { let raw = raw.unwrap_or_default(); + if let Some(track) = &raw.track { + if raw.local.is_some() { + return Err(ManifestError::InvalidArtifact { + artifact, + reason: "`track` only applies to `image` artifacts, not `local` ones".to_string(), + }); + } + if track.contains('@') { + return Err(ManifestError::InvalidArtifact { + artifact, + reason: format!( + "`track` must be a floating reference (a tag to follow), \ + but {track:?} is digest-pinned — there is nothing to follow" + ), + }); + } + if raw.image.is_none() { + return Err(ManifestError::UnresolvedTrack { + artifact, + track: track.clone(), + }); + } + } match (raw.image, raw.local) { (Some(_), Some(_)) => Err(ManifestError::InvalidArtifact { artifact, diff --git a/zcash_local_net/src/container/update.rs b/zcash_local_net/src/container/update.rs new file mode 100644 index 0000000..e733d93 --- /dev/null +++ b/zcash_local_net/src/container/update.rs @@ -0,0 +1,462 @@ +//! Explicitly bump a manifest's image pins to what their tracked +//! references currently denote. +//! +//! Run-time code never consults the registry about what a floating +//! tag means — pins only move through this module (or its CLI face, +//! `zcash-local-net update`). For each container artifact the update: +//! +//! 1. determines the **tracked reference** — the artifact's `track` +//! field when set, else its `image` with any digest stripped (i.e. +//! the tag it was pinned from). A digest-only `image` with no +//! `track` carries no floating preference and is left untouched. +//! 2. resolves that reference against the registry (`pull`, then read +//! the repo digest); +//! 3. rewrites `image` to `@` — tag preserved for +//! readability, digest authoritative — and writes the manifest +//! back in its canonical formatting. +//! +//! The result is a reviewable version-control diff per bump, exactly +//! like a lockfile update. + +use std::path::{Path, PathBuf}; + +use super::ContainerRuntime; +use super::manifest::{ArtifactManifest, ManifestError}; + +/// The outcome of updating one container artifact. +#[derive(Clone, Debug)] +pub struct ImageBump { + /// Which artifact (`validator` / `indexer` / `wallet`). + pub artifact: &'static str, + /// The floating reference that was resolved. + pub tracked: String, + /// The pinned `image` before the update. + pub previous: String, + /// The pinned `image` written by the update. + pub pinned: String, +} + +impl ImageBump { + /// Whether the update actually moved the pin. + pub fn changed(&self) -> bool { + self.previous != self.pinned + } +} + +/// Errors from [`update_manifest_file`]. +#[derive(thiserror::Error, Debug)] +pub enum UpdateError { + /// The manifest could not be read, parsed, or — after bumping — + /// no longer validated. + #[error(transparent)] + Manifest(#[from] ManifestError), + /// An `only` filter named something other than + /// `validator` / `indexer` / `wallet`. + #[error("unknown artifact {name:?} (expected \"validator\", \"indexer\" or \"wallet\")")] + UnknownArtifact { + /// The unrecognized artifact name. + name: String, + }, + /// Resolving a tracked reference against the registry failed. + #[error("artifact `{artifact}`: could not resolve {reference:?}: {detail}")] + Resolve { + /// Which artifact was being updated. + artifact: &'static str, + /// The tracked reference that failed to resolve. + reference: String, + /// What went wrong (runtime CLI stderr or spawn failure). + detail: String, + }, + /// Writing the updated manifest back failed. + #[error("could not write updated manifest {path}: {io_error}")] + Write { + /// The manifest path. + path: PathBuf, + /// Underlying io::Error description. + io_error: String, + }, +} + +/// Resolve every (selected) container artifact's tracked reference and +/// rewrite the manifest's `image` pins in place. `only` restricts the +/// update to the named artifacts (`"validator"` / `"indexer"` / +/// `"wallet"`); empty means all. Returns one [`ImageBump`] per +/// artifact that has a floating preference to follow — including +/// unchanged ones, so callers can report "already current". +/// +/// The file is rewritten in the manifest's canonical formatting +/// (stable field order, two-space indent). Host-process artifacts and +/// digest-only images without `track` are untouched and produce no +/// bump entry. +pub async fn update_manifest_file( + path: &Path, + only: &[&str], +) -> Result, UpdateError> { + update_with_resolver(path, only, resolve_via_registry).await +} + +/// [`update_manifest_file`] with the registry interaction injected — +/// the seam the unit tests use. `resolver(runtime, reference)` returns +/// the digest (`sha256:…`) the reference currently denotes. +async fn update_with_resolver( + path: &Path, + only: &[&str], + resolver: F, +) -> Result, UpdateError> +where + F: AsyncFn(ContainerRuntime, &str) -> Result, +{ + const ARTIFACTS: [&str; 3] = ["validator", "indexer", "wallet"]; + for name in only { + if !ARTIFACTS.contains(name) { + return Err(UpdateError::UnknownArtifact { + name: (*name).to_string(), + }); + } + } + let selected = |name: &str| only.is_empty() || only.contains(&name); + + let text = std::fs::read_to_string(path).map_err(|io_error| ManifestError::Unreadable { + path: path.to_path_buf(), + io_error: io_error.to_string(), + })?; + let mut raw = ArtifactManifest::parse_raw(&text, Some(path))?; + + // The update needs a concrete runtime to resolve through, even + // though the artifacts may not all be launchable yet: resolve the + // declared runtime the same way `from_raw` does. + let runtime = match raw.runtime.as_deref() { + Some(name) => ContainerRuntime::from_manifest_name(name).ok_or_else(|| { + ManifestError::UnknownRuntime { + name: name.to_string(), + } + })?, + None => ContainerRuntime::detect().ok_or(ManifestError::NoContainerRuntime)?, + }; + + let mut bumps = Vec::new(); + for (artifact, entry) in [ + ("validator", raw.validator.as_mut()), + ("indexer", raw.indexer.as_mut()), + ("wallet", raw.wallet.as_mut()), + ] { + let Some(entry) = entry else { continue }; + if !selected(artifact) { + continue; + } + let Some(tracked) = tracked_reference(entry.track.as_deref(), entry.image.as_deref()) + else { + continue; + }; + let digest = resolver(runtime, &tracked) + .await + .map_err(|detail| UpdateError::Resolve { + artifact, + reference: tracked.clone(), + detail, + })?; + let pinned = format!("{tracked}@{digest}"); + bumps.push(ImageBump { + artifact, + tracked, + previous: entry.image.clone().unwrap_or_default(), + pinned: pinned.clone(), + }); + entry.image = Some(pinned); + } + + // Self-check: the bumped manifest must be valid and launchable + // before it replaces the file. + ArtifactManifest::from_raw(raw.clone())?; + + let mut rendered = + serde_json::to_string_pretty(&raw).expect("the manifest schema serializes infallibly"); + rendered.push('\n'); + std::fs::write(path, rendered).map_err(|io_error| UpdateError::Write { + path: path.to_path_buf(), + io_error: io_error.to_string(), + })?; + + Ok(bumps) +} + +/// The floating reference an artifact follows, or `None` when it has +/// no container image or no floating preference (digest-only `image` +/// without `track`). +fn tracked_reference(track: Option<&str>, image: Option<&str>) -> Option { + if let Some(track) = track { + return Some(track.to_string()); + } + let image = image?; + // Strip the digest to recover the tag the image was pinned from. + let base = image.split('@').next().unwrap_or(image); + let last_component = base.rsplit('/').next().unwrap_or(base); + // Digest-only pin (`repo@sha256:…`): no tag, nothing to follow. + last_component.contains(':').then(|| base.to_string()) +} + +/// The live resolver: `pull` the reference (that is the whole point of +/// an update), then read the repo digest it now denotes. Prefers the +/// `RepoDigests` entry whose repository matches the reference; a +/// locally built image with no repo digest cannot be resolved and +/// errs, since a pin that no registry serves is not reproducible +/// elsewhere. +async fn resolve_via_registry( + runtime: ContainerRuntime, + reference: &str, +) -> Result { + let cli = runtime.cli_name(); + let pull = tokio::process::Command::new(cli) + .args(["pull", reference]) + .output() + .await + .map_err(|io_error| format!("`{cli} pull` failed to spawn: {io_error}"))?; + if !pull.status.success() { + return Err(format!( + "`{cli} pull {reference}` exited {}: {}", + pull.status, + String::from_utf8_lossy(&pull.stderr).trim() + )); + } + + let inspect = tokio::process::Command::new(cli) + .args([ + "image", + "inspect", + "--format", + "{{json .RepoDigests}}", + reference, + ]) + .output() + .await + .map_err(|io_error| format!("`{cli} image inspect` failed to spawn: {io_error}"))?; + if !inspect.status.success() { + return Err(format!( + "`{cli} image inspect {reference}` exited {}: {}", + inspect.status, + String::from_utf8_lossy(&inspect.stderr).trim() + )); + } + let digests: Vec = serde_json::from_slice(&inspect.stdout) + .map_err(|error| format!("unparseable RepoDigests output: {error}"))?; + let repository = reference + .split('@') + .next() + .map(|base| { + let last = base.rsplit('/').next().unwrap_or(base); + match last.split_once(':') { + Some((name, _tag)) => &base[..base.len() - last.len() + name.len()], + None => base, + } + }) + .unwrap_or(reference); + digests + .iter() + .find(|entry| entry.split('@').next() == Some(repository)) + .or_else(|| digests.first()) + .and_then(|entry| entry.split('@').nth(1)) + .map(str::to_string) + .ok_or_else(|| { + format!( + "{reference} has no repo digest (locally built image?); \ + a pin no registry serves is not reproducible elsewhere" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracked_reference_prefers_track_over_image() { + assert_eq!( + tracked_reference(Some("zfnd/zebra:latest"), Some("zfnd/zebra:v6.0.0")), + Some("zfnd/zebra:latest".to_string()) + ); + } + + #[test] + fn tracked_reference_recovers_the_tag_from_a_pinned_image() { + assert_eq!( + tracked_reference(None, Some("zfnd/zebra:v6.0.0@sha256:aaaa")), + Some("zfnd/zebra:v6.0.0".to_string()) + ); + assert_eq!( + tracked_reference(None, Some("registry:5000/zebra:v1")), + Some("registry:5000/zebra:v1".to_string()) + ); + } + + #[test] + fn digest_only_image_without_track_has_no_floating_preference() { + assert_eq!( + tracked_reference(None, Some("zfnd/zebra@sha256:aaaa")), + None + ); + // A registry port is not a tag. + assert_eq!( + tracked_reference(None, Some("registry:5000/zebra@sha256:aaaa")), + None + ); + assert_eq!(tracked_reference(None, None), None); + } + + const DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + + fn write_manifest(json: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("zcash-local-net.json"); + std::fs::write(&path, json).unwrap(); + (dir, path) + } + + /// Stub resolver: every reference resolves to [`DIGEST`], and the + /// references asked about are recorded through the returned log. + fn stub_resolver() -> impl AsyncFn(ContainerRuntime, &str) -> Result + use<> { + async |_runtime, _reference| Ok(DIGEST.to_string()) + } + + #[tokio::test] + async fn update_pins_tracked_and_tagged_artifacts_and_rewrites_the_file() { + let (_dir, path) = write_manifest( + r#"{ + "version": 1, + "runtime": "docker", + "validator": { "image": "zfnd/zebra:v6.0.0", "track": "zfnd/zebra:latest" }, + "indexer": { "image": "example.com/zainod:0.4.3" }, + "wallet": { "local": "/builds/zcash-devtool" } + }"#, + ); + let bumps = update_with_resolver(&path, &[], stub_resolver()) + .await + .unwrap(); + + // Two bumps: the tracked validator and the tag-pinned indexer; + // the host-process wallet produces none. + assert_eq!(bumps.len(), 2); + assert_eq!(bumps[0].artifact, "validator"); + assert_eq!(bumps[0].tracked, "zfnd/zebra:latest"); + assert_eq!(bumps[0].pinned, format!("zfnd/zebra:latest@{DIGEST}")); + assert!(bumps[0].changed()); + assert_eq!(bumps[1].artifact, "indexer"); + assert_eq!( + bumps[1].pinned, + format!("example.com/zainod:0.4.3@{DIGEST}") + ); + + // The rewritten file is valid, keeps `track`, and carries the + // new pins. + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains(&format!(r#""image": "zfnd/zebra:latest@{DIGEST}""#))); + assert!(text.contains(r#""track": "zfnd/zebra:latest""#)); + assert!(text.ends_with('\n')); + let reloaded = ArtifactManifest::load(&path).unwrap(); + assert!(reloaded.validator.is_container()); + + // A second update resolving to the same digest is a no-op bump. + let again = update_with_resolver(&path, &[], stub_resolver()) + .await + .unwrap(); + assert!(again.iter().all(|bump| !bump.changed())); + } + + #[tokio::test] + async fn update_mints_the_first_pin_for_a_track_only_artifact() { + let (_dir, path) = write_manifest( + r#"{ + "version": 1, + "runtime": "docker", + "validator": { "track": "zfnd/zebra:latest" } + }"#, + ); + // Not launchable before the update… + assert!(matches!( + ArtifactManifest::load(&path), + Err(ManifestError::UnresolvedTrack { .. }) + )); + // …the update mints the pin… + let bumps = update_with_resolver(&path, &[], stub_resolver()) + .await + .unwrap(); + assert_eq!(bumps.len(), 1); + assert_eq!(bumps[0].previous, ""); + // …and the manifest is launchable afterwards. + assert!( + ArtifactManifest::load(&path) + .unwrap() + .validator + .is_container() + ); + } + + #[tokio::test] + async fn update_respects_the_artifact_filter() { + let (_dir, path) = write_manifest( + r#"{ + "version": 1, + "runtime": "docker", + "validator": { "image": "zfnd/zebra:v6.0.0" }, + "indexer": { "image": "example.com/zainod:0.4.3" } + }"#, + ); + let bumps = update_with_resolver(&path, &["indexer"], stub_resolver()) + .await + .unwrap(); + assert_eq!(bumps.len(), 1); + assert_eq!(bumps[0].artifact, "indexer"); + let text = std::fs::read_to_string(&path).unwrap(); + // The unselected validator pin is untouched. + assert!(text.contains(r#""image": "zfnd/zebra:v6.0.0""#)); + } + + #[tokio::test] + async fn update_rejects_unknown_artifact_filters() { + let (_dir, path) = write_manifest(r#"{ "version": 1 }"#); + let error = update_with_resolver(&path, &["validatr"], stub_resolver()) + .await + .unwrap_err(); + assert!(matches!(error, UpdateError::UnknownArtifact { name } if name == "validatr")); + } + + #[tokio::test] + async fn digest_only_pin_without_track_is_left_untouched() { + let manifest = format!( + r#"{{ + "version": 1, + "runtime": "docker", + "validator": {{ "image": "zfnd/zebra@{DIGEST}" }} + }}"#, + ); + let (_dir, path) = write_manifest(&manifest); + let bumps = update_with_resolver(&path, &[], stub_resolver()) + .await + .unwrap(); + assert!(bumps.is_empty()); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains(&format!(r#""image": "zfnd/zebra@{DIGEST}""#))); + } + + #[tokio::test] + async fn resolver_failure_surfaces_without_touching_the_file() { + let original = r#"{ + "version": 1, + "runtime": "docker", + "validator": { "image": "zfnd/zebra:v6.0.0" } + }"#; + let (_dir, path) = write_manifest(original); + let error = update_with_resolver(&path, &[], async |_runtime, _reference| { + Err("registry unreachable".to_string()) + }) + .await + .unwrap_err(); + assert!(matches!( + error, + UpdateError::Resolve { + artifact: "validator", + .. + } + )); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + } +} From a5fdc7328bd047343bce8d5029f5349d43274645 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 00:45:34 +0000 Subject: [PATCH 4/6] feat(zcash_local_net)!: switch the artifact manifest format from JSON to TOML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TOML is the configuration language everything else in this ecosystem speaks (zebrad.toml, zindexer.toml, Cargo.toml) and, unlike JSON, carries comments — the template printed by `zcash-local-net template` now documents every field inline. The manifest file is `zcash-local-net.toml`, `ArtifactManifest::from_json_str` is now `from_toml_str`, `template_json` is now `template_toml`, and `zcash-local-net update` rewrites the file as canonical TOML (declaration-order fields via the serializing schema). Adds the `toml` crate (0.8) as a dependency; its stack (toml_edit, toml_datetime, serde_spanned, winnow) is MIT/Apache-licensed within the cargo-deny allow list, and cargo-vet exemptions for the toml 0.8 line already exist in supply-chain/config.toml. This reverses the earlier zero-new-deps JSON decision at the maintainer's request. Schema, semantics, pinning enforcement, tracking, preflight, and the CLI behave identically — only the surface syntax changed. All fixtures and the live CLI validation were converted; three previously-missing manifest-level `track` rule tests (track on local artifacts, digest-pinned track, track-only launch refusal) were added alongside. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015dRfXaAyhrUC5LwgEGHaNi --- Cargo.lock | 82 ++++++ zcash_local_net/CHANGELOG.md | 10 +- zcash_local_net/Cargo.toml | 1 + zcash_local_net/src/bin/zcash-local-net.rs | 10 +- zcash_local_net/src/container.rs | 2 +- zcash_local_net/src/container/manifest.rs | 303 ++++++++++++++------- zcash_local_net/src/container/update.rs | 88 +++--- zcash_local_net/src/lib.rs | 6 +- zcash_local_net/tests/containers.rs | 4 +- 9 files changed, 351 insertions(+), 155 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d8f702..0b1955f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,12 @@ dependencies = [ "syn", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -283,6 +289,12 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -480,6 +492,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -783,6 +805,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -969,6 +1000,47 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -1220,6 +1292,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "workbench" version = "0.1.0" @@ -1268,6 +1349,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "toml", "tracing", "tracing-subscriber", "zingo-consensus", diff --git a/zcash_local_net/CHANGELOG.md b/zcash_local_net/CHANGELOG.md index bdb61d8..9dea7b5 100644 --- a/zcash_local_net/CHANGELOG.md +++ b/zcash_local_net/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 container. Stopping a containerized process force-removes the container by name (`zcash-local-net---`). - **Artifact manifest** (`container::manifest::ArtifactManifest`): a - small JSON file consumers keep with their test setup describing + small TOML file consumers keep with their test setup describing where each artifact comes from — an `image` (with optional `entrypoint` and `pull` policy `never`/`if-missing`/`always`) or a `local` path to a locally built binary (the **escape hatch**; also @@ -67,11 +67,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`zcash-local-net` CLI** (new binary in this crate): `zcash-local-net preflight [--manifest ]` runs the pre-check from a shell (exit 0/1), resolving the manifest from the flag, then - `$ZCASH_LOCAL_NET_MANIFEST`, then `./zcash-local-net.json`, then the + `$ZCASH_LOCAL_NET_MANIFEST`, then `./zcash-local-net.toml`, then the built-in host-process default; `zcash-local-net template` prints a starting-point manifest. Intended use: - `zcash-local-net preflight --manifest m.json && - ZCASH_LOCAL_NET_MANIFEST=m.json cargo nextest run`. + `zcash-local-net preflight --manifest m.toml && + ZCASH_LOCAL_NET_MANIFEST=m.toml cargo nextest run`. The `toml` + crate is a new dependency (already covered by existing cargo-vet + exemptions for its stack). ### Changed diff --git a/zcash_local_net/Cargo.toml b/zcash_local_net/Cargo.toml index 01e9036..8e47cc8 100644 --- a/zcash_local_net/Cargo.toml +++ b/zcash_local_net/Cargo.toml @@ -35,6 +35,7 @@ hex = { workspace = true } tokio = { workspace = true, features = ["io-util", "net", "rt", "time", "process"] } serde = { version = "1.0", features = ["derive"] } sha2 = "0.10" +toml = "0.8" [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "io-util"] } diff --git a/zcash_local_net/src/bin/zcash-local-net.rs b/zcash_local_net/src/bin/zcash-local-net.rs index 977bd47..c9b3359 100644 --- a/zcash_local_net/src/bin/zcash-local-net.rs +++ b/zcash_local_net/src/bin/zcash-local-net.rs @@ -7,8 +7,8 @@ //! binary is one clear report instead of N launch failures: //! //! ```sh -//! zcash-local-net preflight --manifest ci-artifacts.json \ -//! && ZCASH_LOCAL_NET_MANIFEST=ci-artifacts.json cargo nextest run +//! zcash-local-net preflight --manifest ci-artifacts.toml \ +//! && ZCASH_LOCAL_NET_MANIFEST=ci-artifacts.toml cargo nextest run //! ``` //! //! Argument parsing is hand-rolled: two subcommands and one flag do @@ -44,13 +44,13 @@ COMMANDS: this command is the only thing that moves them. Name artifacts to bump a subset. The file is rewritten in canonical formatting; review the diff and commit it. - template Print an example manifest (JSON) to stdout. + template Print an example manifest (TOML) to stdout. help Print this message. MANIFEST RESOLUTION: 1. --manifest 2. $ZCASH_LOCAL_NET_MANIFEST - 3. ./zcash-local-net.json, if it exists + 3. ./zcash-local-net.toml, if it exists 4. none — for `preflight`, the built-in default (every artifact resolved as a host process via TEST_BINARIES_DIR / PATH); `update` needs a file to rewrite and exits with an error. @@ -62,7 +62,7 @@ fn main() -> ExitCode { Some("preflight") => preflight(&args[1..]), Some("update") => update(&args[1..]), Some("template") => { - print!("{}", ArtifactManifest::template_json()); + print!("{}", ArtifactManifest::template_toml()); ExitCode::SUCCESS } Some("help") | Some("--help") | Some("-h") => { diff --git a/zcash_local_net/src/container.rs b/zcash_local_net/src/container.rs index eca85a3..c0fd5e5 100644 --- a/zcash_local_net/src/container.rs +++ b/zcash_local_net/src/container.rs @@ -27,7 +27,7 @@ //! from the host. //! //! Consumers describe which artifacts come from where with an -//! [`manifest::ArtifactManifest`] — a small JSON file that can be +//! [`manifest::ArtifactManifest`] — a small TOML file that can be //! validated ahead of a test run with [`manifest::ArtifactManifest::preflight`] //! (also exposed by the `zcash-local-net` CLI as `zcash-local-net //! preflight`). diff --git a/zcash_local_net/src/container/manifest.rs b/zcash_local_net/src/container/manifest.rs index 6e31b70..3a3a897 100644 --- a/zcash_local_net/src/container/manifest.rs +++ b/zcash_local_net/src/container/manifest.rs @@ -8,21 +8,23 @@ //! developer iterating on zebrad or zaino flips one artifact to a //! `local` path — the escape hatch — without touching the tests. //! -//! The format is JSON (this crate already depends on serde_json; -//! adding a TOML parser was rejected to keep the supply-chain surface -//! flat). `zcash-local-net template` prints a starting point. +//! The format is TOML — the configuration language everything else in +//! this ecosystem already speaks (`zebrad.toml`, `zindexer.toml`, +//! `Cargo.toml`), comments included. `zcash-local-net template` +//! prints a starting point. //! -//! ```json -//! { -//! "version": 1, -//! "runtime": "docker", -//! "validator": { -//! "image": "zfnd/zebra:v6.0.0", -//! "track": "zfnd/zebra:latest" -//! }, -//! "indexer": { "local": "/home/dev/zaino/target/release/zainod" }, -//! "wallet": {} -//! } +//! ```toml +//! version = 1 +//! runtime = "docker" +//! +//! [validator] +//! image = "zfnd/zebra:v6.0.0" +//! track = "zfnd/zebra:latest" +//! +//! [indexer] +//! local = "/home/dev/zaino/target/release/zainod" +//! +//! [wallet] //! ``` //! //! ## Intent vs pin: bumping references explicitly @@ -36,7 +38,7 @@ //! the pin is a deliberate act: //! //! ```sh -//! zcash-local-net update --manifest ci-artifacts.json +//! zcash-local-net update --manifest ci-artifacts.toml //! ``` //! //! resolves each artifact's tracked reference against the registry, @@ -82,7 +84,7 @@ use crate::{ /// The manifest file name looked for in the working directory when no /// explicit path is given (CLI convention). -pub const DEFAULT_MANIFEST_FILENAME: &str = "zcash-local-net.json"; +pub const DEFAULT_MANIFEST_FILENAME: &str = "zcash-local-net.toml"; /// Environment variable naming the manifest file test suites should /// load (see [`ArtifactManifest::from_env`]). @@ -102,7 +104,7 @@ pub enum ManifestError { /// Underlying io::Error description. io_error: String, }, - /// The manifest is not valid JSON, or does not match the schema + /// The manifest is not valid TOML, or does not match the schema /// (unknown fields are rejected so typos fail loudly). #[error("manifest {path:?} did not parse: {detail}")] Parse { @@ -234,9 +236,9 @@ impl ArtifactManifest { Self::parse(&text, Some(path)) } - /// Parse and validate a manifest from a JSON string. - pub fn from_json_str(json: &str) -> Result { - Self::parse(json, None) + /// Parse and validate a manifest from a TOML string. + pub fn from_toml_str(toml_text: &str) -> Result { + Self::parse(toml_text, None) } /// Load the manifest named by the `ZCASH_LOCAL_NET_MANIFEST` @@ -260,20 +262,22 @@ impl ArtifactManifest { } } - fn parse(json: &str, path: Option<&Path>) -> Result { - Self::from_raw(Self::parse_raw(json, path)?) + fn parse(toml_text: &str, path: Option<&Path>) -> Result { + Self::from_raw(Self::parse_raw(toml_text, path)?) } /// Parse the serde-facing schema and check the version, without /// resolving artifacts. `zcash-local-net update` operates on this /// representation: a manifest whose `track`-only artifacts are not /// yet launchable must still be readable for updating. - pub(crate) fn parse_raw(json: &str, path: Option<&Path>) -> Result { - let raw: RawManifest = - serde_json::from_str(json).map_err(|error| ManifestError::Parse { - path: path.map(Path::to_path_buf), - detail: error.to_string(), - })?; + pub(crate) fn parse_raw( + toml_text: &str, + path: Option<&Path>, + ) -> Result { + let raw: RawManifest = toml::from_str(toml_text).map_err(|error| ManifestError::Parse { + path: path.map(Path::to_path_buf), + detail: error.to_string(), + })?; if raw.version != SUPPORTED_VERSION { return Err(ManifestError::UnsupportedVersion { found: raw.version }); } @@ -357,21 +361,34 @@ impl ArtifactManifest { /// An example manifest, printed by `zcash-local-net template`. /// Kept parseable by a unit test. - pub fn template_json() -> &'static str { - r#"{ - "version": 1, - "runtime": "docker", - "validator": { - "image": "zfnd/zebra:v6.0.0", - "track": "zfnd/zebra:latest", - "entrypoint": "zebrad", - "pull": "if-missing" - }, - "indexer": { - "local": "/home/dev/zaino/target/release/zainod" - }, - "wallet": {} -} + pub fn template_toml() -> &'static str { + r#"# zcash_local_net artifact manifest. +# Validate with `zcash-local-net preflight`; bump image pins with +# `zcash-local-net update`. Point tests at it via ZCASH_LOCAL_NET_MANIFEST. +version = 1 + +# "docker" or "podman"; omit to auto-detect (docker first). +runtime = "docker" + +[validator] +# The binary's actual published image. Must be pinned: a digest +# (`repo@sha256:...`) or an explicit non-`latest` tag. +image = "zfnd/zebra:v6.0.0" +# Optional floating reference to follow; `zcash-local-net update` +# resolves it and rewrites `image` to the digest-pinned result. +track = "zfnd/zebra:latest" +# Optional; defaults to the artifact's binary name ("zebrad"). +entrypoint = "zebrad" +# "never" | "if-missing" (default) | "always" +pull = "if-missing" + +[indexer] +# Escape hatch: a locally built binary runs as a host process. +local = "/home/dev/zaino/target/release/zainod" + +# An empty (or omitted) table resolves the binary via +# TEST_BINARIES_DIR / PATH, the historical default. +[wallet] "# } } @@ -481,7 +498,7 @@ mod tests { #[test] fn empty_manifest_resolves_everything_to_host_defaults() { - let manifest = ArtifactManifest::from_json_str(r#"{ "version": 1 }"#).unwrap(); + let manifest = ArtifactManifest::from_toml_str("version = 1\n").unwrap(); for source in [&manifest.validator, &manifest.indexer, &manifest.wallet] { assert!(matches!( source, @@ -492,11 +509,13 @@ mod tests { #[test] fn local_escape_hatch_resolves_to_explicit_binary() { - let manifest = ArtifactManifest::from_json_str( - r#"{ - "version": 1, - "indexer": { "local": "/builds/zainod" } - }"#, + let manifest = ArtifactManifest::from_toml_str( + r#" + version = 1 + + [indexer] + local = "/builds/zainod" + "#, ) .unwrap(); let ArtifactSource::HostProcess { @@ -515,16 +534,16 @@ mod tests { #[test] fn image_artifact_carries_entrypoint_and_pull_policy() { - let manifest = ArtifactManifest::from_json_str( - r#"{ - "version": 1, - "runtime": "podman", - "validator": { - "image": "example.com/zebra:v6.0.0", - "entrypoint": "/usr/bin/zebrad", - "pull": "never" - } - }"#, + let manifest = ArtifactManifest::from_toml_str( + r#" + version = 1 + runtime = "podman" + + [validator] + image = "example.com/zebra:v6.0.0" + entrypoint = "/usr/bin/zebrad" + pull = "never" + "#, ) .unwrap(); let ArtifactSource::Container(image) = &manifest.validator else { @@ -538,12 +557,15 @@ mod tests { #[test] fn image_and_local_are_mutually_exclusive() { - let error = ArtifactManifest::from_json_str( - r#"{ - "version": 1, - "runtime": "docker", - "validator": { "image": "zebra", "local": "/builds/zebrad" } - }"#, + let error = ArtifactManifest::from_toml_str( + r#" + version = 1 + runtime = "docker" + + [validator] + image = "zebra" + local = "/builds/zebrad" + "#, ) .unwrap_err(); assert!(matches!( @@ -557,11 +579,14 @@ mod tests { #[test] fn image_only_fields_are_rejected_on_host_artifacts() { - let error = ArtifactManifest::from_json_str( - r#"{ - "version": 1, - "wallet": { "local": "/builds/zcash-devtool", "pull": "always" } - }"#, + let error = ArtifactManifest::from_toml_str( + r#" + version = 1 + + [wallet] + local = "/builds/zcash-devtool" + pull = "always" + "#, ) .unwrap_err(); assert!(matches!( @@ -573,10 +598,79 @@ mod tests { )); } + #[test] + fn track_is_rejected_on_local_artifacts() { + let error = ArtifactManifest::from_toml_str( + r#" + version = 1 + + [indexer] + local = "/builds/zainod" + track = "zainod:latest" + "#, + ) + .unwrap_err(); + assert!(matches!( + error, + ManifestError::InvalidArtifact { + artifact: "indexer", + .. + } + )); + } + + #[test] + fn digest_pinned_track_is_rejected() { + let error = ArtifactManifest::from_toml_str( + r#" + version = 1 + runtime = "docker" + + [validator] + image = "zfnd/zebra:v6.0.0" + track = "zfnd/zebra@sha256:aaaa" + "#, + ) + .unwrap_err(); + assert!(matches!( + error, + ManifestError::InvalidArtifact { + artifact: "validator", + .. + } + )); + } + + #[test] + fn track_without_image_is_not_launchable() { + let error = ArtifactManifest::from_toml_str( + r#" + version = 1 + runtime = "docker" + + [validator] + track = "zfnd/zebra:latest" + "#, + ) + .unwrap_err(); + assert!(matches!( + error, + ManifestError::UnresolvedTrack { + artifact: "validator", + .. + } + )); + } + #[test] fn unknown_keys_fail_loudly() { - let error = ArtifactManifest::from_json_str( - r#"{ "version": 1, "validatr": { "image": "zebra" } }"#, + let error = ArtifactManifest::from_toml_str( + r#" + version = 1 + + [validatr] + image = "zebra:v1" + "#, ) .unwrap_err(); assert!(matches!(error, ManifestError::Parse { .. }), "{error:?}"); @@ -584,7 +678,7 @@ mod tests { #[test] fn unsupported_version_is_rejected() { - let error = ArtifactManifest::from_json_str(r#"{ "version": 2 }"#).unwrap_err(); + let error = ArtifactManifest::from_toml_str("version = 2\n").unwrap_err(); assert!(matches!( error, ManifestError::UnsupportedVersion { found: 2 } @@ -593,8 +687,14 @@ mod tests { #[test] fn unknown_runtime_is_rejected() { - let error = ArtifactManifest::from_json_str( - r#"{ "version": 1, "runtime": "containerd", "validator": { "image": "zebra" } }"#, + let error = ArtifactManifest::from_toml_str( + r#" + version = 1 + runtime = "containerd" + + [validator] + image = "zebra:v1" + "#, ) .unwrap_err(); assert!(matches!(error, ManifestError::UnknownRuntime { name } if name == "containerd")); @@ -602,12 +702,15 @@ mod tests { #[test] fn unknown_pull_policy_is_rejected() { - let error = ArtifactManifest::from_json_str( - r#"{ - "version": 1, - "runtime": "docker", - "validator": { "image": "zebra:v1", "pull": "sometimes" } - }"#, + let error = ArtifactManifest::from_toml_str( + r#" + version = 1 + runtime = "docker" + + [validator] + image = "zebra:v1" + pull = "sometimes" + "#, ) .unwrap_err(); assert!(matches!( @@ -630,12 +733,8 @@ mod tests { // A registry port is not a tag. ("registry:5000/zebra", "no tag or digest"), ] { - let error = ArtifactManifest::from_json_str(&format!( - r#"{{ - "version": 1, - "runtime": "docker", - "validator": {{ "image": "{image}" }} - }}"#, + let error = ArtifactManifest::from_toml_str(&format!( + "version = 1\nruntime = \"docker\"\n\n[validator]\nimage = \"{image}\"\n", )) .unwrap_err(); let ManifestError::UnpinnedImage { @@ -665,12 +764,8 @@ mod tests { "zfnd/zebra:v6.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000", "zln-test-daemon:local", ] { - let manifest = ArtifactManifest::from_json_str(&format!( - r#"{{ - "version": 1, - "runtime": "docker", - "validator": {{ "image": "{image}" }} - }}"#, + let manifest = ArtifactManifest::from_toml_str(&format!( + "version = 1\nruntime = \"docker\"\n\n[validator]\nimage = \"{image}\"\n", )) .unwrap_or_else(|error| panic!("{image:?} should be accepted, got {error:?}")); assert!(manifest.validator.is_container()); @@ -684,13 +779,19 @@ mod tests { /// regardless of what's installed. #[test] fn local_only_manifest_needs_no_runtime() { - let manifest = ArtifactManifest::from_json_str( - r#"{ - "version": 1, - "validator": { "local": "/builds/zebrad" }, - "indexer": { "local": "/builds/zainod" }, - "wallet": { "local": "/builds/zcash-devtool" } - }"#, + let manifest = ArtifactManifest::from_toml_str( + r#" + version = 1 + + [validator] + local = "/builds/zebrad" + + [indexer] + local = "/builds/zainod" + + [wallet] + local = "/builds/zcash-devtool" + "#, ) .unwrap(); assert!(!manifest.validator.is_container()); @@ -701,7 +802,7 @@ mod tests { /// hermetic on hosts without either installed. #[test] fn template_parses() { - let manifest = ArtifactManifest::from_json_str(ArtifactManifest::template_json()).unwrap(); + let manifest = ArtifactManifest::from_toml_str(ArtifactManifest::template_toml()).unwrap(); assert!(manifest.validator.is_container()); assert!(matches!( &manifest.indexer, diff --git a/zcash_local_net/src/container/update.rs b/zcash_local_net/src/container/update.rs index e733d93..1f5ecba 100644 --- a/zcash_local_net/src/container/update.rs +++ b/zcash_local_net/src/container/update.rs @@ -169,9 +169,7 @@ where // before it replaces the file. ArtifactManifest::from_raw(raw.clone())?; - let mut rendered = - serde_json::to_string_pretty(&raw).expect("the manifest schema serializes infallibly"); - rendered.push('\n'); + let rendered = toml::to_string(&raw).expect("the manifest schema serializes infallibly"); std::fs::write(path, rendered).map_err(|io_error| UpdateError::Write { path: path.to_path_buf(), io_error: io_error.to_string(), @@ -304,10 +302,10 @@ mod tests { const DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; - fn write_manifest(json: &str) -> (tempfile::TempDir, PathBuf) { + fn write_manifest(toml_text: &str) -> (tempfile::TempDir, PathBuf) { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("zcash-local-net.json"); - std::fs::write(&path, json).unwrap(); + let path = dir.path().join("zcash-local-net.toml"); + std::fs::write(&path, toml_text).unwrap(); (dir, path) } @@ -320,13 +318,20 @@ mod tests { #[tokio::test] async fn update_pins_tracked_and_tagged_artifacts_and_rewrites_the_file() { let (_dir, path) = write_manifest( - r#"{ - "version": 1, - "runtime": "docker", - "validator": { "image": "zfnd/zebra:v6.0.0", "track": "zfnd/zebra:latest" }, - "indexer": { "image": "example.com/zainod:0.4.3" }, - "wallet": { "local": "/builds/zcash-devtool" } - }"#, + r#" + version = 1 + runtime = "docker" + + [validator] + image = "zfnd/zebra:v6.0.0" + track = "zfnd/zebra:latest" + + [indexer] + image = "example.com/zainod:0.4.3" + + [wallet] + local = "/builds/zcash-devtool" + "#, ); let bumps = update_with_resolver(&path, &[], stub_resolver()) .await @@ -348,8 +353,11 @@ mod tests { // The rewritten file is valid, keeps `track`, and carries the // new pins. let text = std::fs::read_to_string(&path).unwrap(); - assert!(text.contains(&format!(r#""image": "zfnd/zebra:latest@{DIGEST}""#))); - assert!(text.contains(r#""track": "zfnd/zebra:latest""#)); + assert!( + text.contains(&format!(r#"image = "zfnd/zebra:latest@{DIGEST}""#)), + "{text}" + ); + assert!(text.contains(r#"track = "zfnd/zebra:latest""#), "{text}"); assert!(text.ends_with('\n')); let reloaded = ArtifactManifest::load(&path).unwrap(); assert!(reloaded.validator.is_container()); @@ -364,11 +372,13 @@ mod tests { #[tokio::test] async fn update_mints_the_first_pin_for_a_track_only_artifact() { let (_dir, path) = write_manifest( - r#"{ - "version": 1, - "runtime": "docker", - "validator": { "track": "zfnd/zebra:latest" } - }"#, + r#" + version = 1 + runtime = "docker" + + [validator] + track = "zfnd/zebra:latest" + "#, ); // Not launchable before the update… assert!(matches!( @@ -393,12 +403,16 @@ mod tests { #[tokio::test] async fn update_respects_the_artifact_filter() { let (_dir, path) = write_manifest( - r#"{ - "version": 1, - "runtime": "docker", - "validator": { "image": "zfnd/zebra:v6.0.0" }, - "indexer": { "image": "example.com/zainod:0.4.3" } - }"#, + r#" + version = 1 + runtime = "docker" + + [validator] + image = "zfnd/zebra:v6.0.0" + + [indexer] + image = "example.com/zainod:0.4.3" + "#, ); let bumps = update_with_resolver(&path, &["indexer"], stub_resolver()) .await @@ -407,12 +421,12 @@ mod tests { assert_eq!(bumps[0].artifact, "indexer"); let text = std::fs::read_to_string(&path).unwrap(); // The unselected validator pin is untouched. - assert!(text.contains(r#""image": "zfnd/zebra:v6.0.0""#)); + assert!(text.contains(r#"image = "zfnd/zebra:v6.0.0""#), "{text}"); } #[tokio::test] async fn update_rejects_unknown_artifact_filters() { - let (_dir, path) = write_manifest(r#"{ "version": 1 }"#); + let (_dir, path) = write_manifest("version = 1\n"); let error = update_with_resolver(&path, &["validatr"], stub_resolver()) .await .unwrap_err(); @@ -422,11 +436,7 @@ mod tests { #[tokio::test] async fn digest_only_pin_without_track_is_left_untouched() { let manifest = format!( - r#"{{ - "version": 1, - "runtime": "docker", - "validator": {{ "image": "zfnd/zebra@{DIGEST}" }} - }}"#, + "version = 1\nruntime = \"docker\"\n\n[validator]\nimage = \"zfnd/zebra@{DIGEST}\"\n", ); let (_dir, path) = write_manifest(&manifest); let bumps = update_with_resolver(&path, &[], stub_resolver()) @@ -434,16 +444,16 @@ mod tests { .unwrap(); assert!(bumps.is_empty()); let text = std::fs::read_to_string(&path).unwrap(); - assert!(text.contains(&format!(r#""image": "zfnd/zebra@{DIGEST}""#))); + assert!( + text.contains(&format!(r#"image = "zfnd/zebra@{DIGEST}""#)), + "{text}" + ); } #[tokio::test] async fn resolver_failure_surfaces_without_touching_the_file() { - let original = r#"{ - "version": 1, - "runtime": "docker", - "validator": { "image": "zfnd/zebra:v6.0.0" } - }"#; + let original = + "version = 1\nruntime = \"docker\"\n\n[validator]\nimage = \"zfnd/zebra:v6.0.0\"\n"; let (_dir, path) = write_manifest(original); let error = update_with_resolver(&path, &[], async |_runtime, _reference| { Err("registry unreachable".to_string()) diff --git a/zcash_local_net/src/lib.rs b/zcash_local_net/src/lib.rs index 6aa5b83..d0163a0 100644 --- a/zcash_local_net/src/lib.rs +++ b/zcash_local_net/src/lib.rs @@ -47,7 +47,7 @@ //! image** (via the `docker` or `podman` CLI) instead of a host //! binary. Which artifacts come from where is described by an //! [artifact manifest](crate::container::manifest::ArtifactManifest) -//! — a small JSON file consumers keep with their test setup — with a +//! — a small TOML file consumers keep with their test setup — with a //! `local` **escape hatch** for artifacts built locally from source. //! The manifest doubles as a pre-check: validate it against the //! current environment with @@ -56,8 +56,8 @@ //! before invoking the test runner: //! //! ```sh -//! zcash-local-net preflight --manifest ci-artifacts.json \ -//! && ZCASH_LOCAL_NET_MANIFEST=ci-artifacts.json cargo nextest run +//! zcash-local-net preflight --manifest ci-artifacts.toml \ +//! && ZCASH_LOCAL_NET_MANIFEST=ci-artifacts.toml cargo nextest run //! ``` //! //! See [`crate::container`] for the mechanics (foreground containers diff --git a/zcash_local_net/tests/containers.rs b/zcash_local_net/tests/containers.rs index 13f0c56..659a9f3 100644 --- a/zcash_local_net/tests/containers.rs +++ b/zcash_local_net/tests/containers.rs @@ -7,8 +7,8 @@ //! Run them explicitly once the environment provides both: //! //! ```sh -//! zcash-local-net preflight --manifest ci-artifacts.json -//! ZCASH_LOCAL_NET_MANIFEST=ci-artifacts.json \ +//! zcash-local-net preflight --manifest ci-artifacts.toml +//! ZCASH_LOCAL_NET_MANIFEST=ci-artifacts.toml \ //! cargo test -p zcash_local_net --test containers -- --ignored //! ``` //! From 14750cb8ba9c89fb2a5a37067b4dabdff2d5710e Mon Sep 17 00:00:00 2001 From: zancas Date: Sat, 11 Jul 2026 13:12:37 -0700 Subject: [PATCH 5/6] refactor(zcash_local_net): replace the toml crate with an in-repo manifest-dialect module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `toml = "0.8"` dependency and serve the artifact manifest with a crate-internal `toml` module instead. The module is a drop-in for the two entry points the crate used — `from_str` and `to_string`, with the crate's `de::Error` / `ser::Error` split — so both call sites are byte-for-byte unchanged apart from their imports. The dependency list returns to exactly what `dev` already carries, and `toml`, `toml_datetime`, `toml_edit`, and `toml_write` all leave `Cargo.lock`. The module is deliberately NOT a full TOML parser. It implements only the minimal dialect the manifest needs — comments, bare keys, table headers, basic strings with the standard escapes, and decimal integers — and rejects every other TOML construct with an error that names the construct and its line, never silently misreading a file. The module's rustdoc header is the single authoritative statement of the dialect; the manifest documentation points at it. Parsing produces a `serde_json::Value` tree and bridges through `serde_json::from_value`, so the schema stays defined once, on the `RawManifest` derives, and unknown-field rejection and type checking keep working unchanged. Rendering is a minimal serde `Serializer` that emits fields in declaration order in the exact shape `toml` 0.8.23 emitted. Equivalence with `toml` 0.8.23 was proven by a live A/B run against the real crate before its removal — byte-for-byte on rendering, and value-for-value on parsing across the template, comment placement, indentation, and every escape form — and is frozen as provenance-commented golden fixtures in the module's tests, which keep running offline. The one deliberate divergence is documented and tested: strings needing escapes render as basic strings with backslash escapes, where the crate switched to literal strings, so the renderer never emits what the dialect cannot read back. Also correct the `update_manifest_file` documentation, which promised a two-space indent the old renderer never produced, and record the Manifest dialect term in CONTEXT.md. Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 8 + Cargo.lock | 82 -- zcash_local_net/Cargo.toml | 1 - zcash_local_net/src/container/manifest.rs | 7 +- zcash_local_net/src/container/update.rs | 4 +- zcash_local_net/src/lib.rs | 1 + zcash_local_net/src/toml.rs | 915 ++++++++++++++++++++++ 7 files changed, 932 insertions(+), 86 deletions(-) create mode 100644 zcash_local_net/src/toml.rs diff --git a/CONTEXT.md b/CONTEXT.md index de8edbc..e806c74 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -91,3 +91,11 @@ heights. The Validator is configured with heights exactly once, at launch; the Indexer and the wallet client derive theirs from it, and supplying heights to those components by hand is unrepresentable. _Avoid_: provider heights, custom heights, caller heights + +**Manifest dialect**: +The deliberately minimal subset of TOML that artifact manifests are +written in: comments, bare keys, tables, basic strings, and decimal +integers. A manifest is either inside the dialect or rejected loudly at +load time — no construct outside it is ever silently misread. Full TOML +is not the contract. +_Avoid_: full TOML (as a description of what manifests may contain) diff --git a/Cargo.lock b/Cargo.lock index 0b1955f..3d8f702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,12 +204,6 @@ dependencies = [ "syn", ] -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "errno" version = "0.3.14" @@ -289,12 +283,6 @@ dependencies = [ "r-efi", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "heck" version = "0.5.0" @@ -492,16 +480,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -805,15 +783,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1000,47 +969,6 @@ dependencies = [ "syn", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "tower" version = "0.5.3" @@ -1292,15 +1220,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "workbench" version = "0.1.0" @@ -1349,7 +1268,6 @@ dependencies = [ "tempfile", "thiserror", "tokio", - "toml", "tracing", "tracing-subscriber", "zingo-consensus", diff --git a/zcash_local_net/Cargo.toml b/zcash_local_net/Cargo.toml index 8e47cc8..01e9036 100644 --- a/zcash_local_net/Cargo.toml +++ b/zcash_local_net/Cargo.toml @@ -35,7 +35,6 @@ hex = { workspace = true } tokio = { workspace = true, features = ["io-util", "net", "rt", "time", "process"] } serde = { version = "1.0", features = ["derive"] } sha2 = "0.10" -toml = "0.8" [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "io-util"] } diff --git a/zcash_local_net/src/container/manifest.rs b/zcash_local_net/src/container/manifest.rs index 3a3a897..16e16c7 100644 --- a/zcash_local_net/src/container/manifest.rs +++ b/zcash_local_net/src/container/manifest.rs @@ -11,7 +11,10 @@ //! The format is TOML — the configuration language everything else in //! this ecosystem already speaks (`zebrad.toml`, `zindexer.toml`, //! `Cargo.toml`), comments included. `zcash-local-net template` -//! prints a starting point. +//! prints a starting point. Manifests are parsed by a deliberately +//! minimal in-repo subset of TOML, not a full TOML parser; the +//! crate-internal `toml` module documents the exact dialect, and +//! rejects — loudly, never silently — every construct outside it. //! //! ```toml //! version = 1 @@ -78,7 +81,7 @@ use serde::Deserialize; use super::{ArtifactSource, ContainerImage, ContainerRuntime, PullPolicy}; use crate::{ - LocalNet, error::LaunchError, indexer::zainod::Zainod, indexer::zainod::ZainodConfig, + LocalNet, error::LaunchError, indexer::zainod::Zainod, indexer::zainod::ZainodConfig, toml, validator::zebrad::Zebrad, validator::zebrad::ZebradConfig, }; diff --git a/zcash_local_net/src/container/update.rs b/zcash_local_net/src/container/update.rs index 1f5ecba..6163668 100644 --- a/zcash_local_net/src/container/update.rs +++ b/zcash_local_net/src/container/update.rs @@ -22,6 +22,7 @@ use std::path::{Path, PathBuf}; use super::ContainerRuntime; use super::manifest::{ArtifactManifest, ManifestError}; +use crate::toml; /// The outcome of updating one container artifact. #[derive(Clone, Debug)] @@ -85,7 +86,8 @@ pub enum UpdateError { /// unchanged ones, so callers can report "already current". /// /// The file is rewritten in the manifest's canonical formatting -/// (stable field order, two-space indent). Host-process artifacts and +/// (fields in schema declaration order, one blank line before each +/// artifact table, no indentation). Host-process artifacts and /// digest-only images without `track` are untouched and produce no /// bump entry. pub async fn update_manifest_file( diff --git a/zcash_local_net/src/lib.rs b/zcash_local_net/src/lib.rs index d0163a0..df513d3 100644 --- a/zcash_local_net/src/lib.rs +++ b/zcash_local_net/src/lib.rs @@ -84,6 +84,7 @@ mod backend; mod launch; mod macros; mod poll; +mod toml; use indexer::Indexer; use validator::Validator; diff --git a/zcash_local_net/src/toml.rs b/zcash_local_net/src/toml.rs new file mode 100644 index 0000000..0b049af --- /dev/null +++ b/zcash_local_net/src/toml.rs @@ -0,0 +1,915 @@ +//! A crate-internal, minimal TOML-subset parser and renderer. +//! +//! This module is a drop-in replacement for the two entry points this +//! crate used from the `toml` crate — [`from_str`] and [`to_string`], +//! with the crate's `de::Error` / `ser::Error` split — implemented +//! in-repo so the manifest machinery carries no TOML dependency. +//! +//! # This is NOT a full TOML parser +//! +//! It deliberately implements only the minimal dialect the artifact +//! manifest needs. A document is accepted when it uses nothing but: +//! +//! - comments (`# …`), whole-line or trailing a value, and blank lines; +//! - bare keys (ASCII letters, digits, `_`, `-`); +//! - table headers (`[validator]`) naming a single bare key, each +//! header appearing at most once; +//! - basic strings (`"…"`) confined to one line, with the standard +//! TOML escapes (`\b \t \n \f \r \" \\ \uXXXX \UXXXXXXXX`); +//! - decimal integers (optional leading `-`, no underscores). +//! +//! Every other TOML construct is rejected with an error naming the +//! construct and its line — never silently misread. The rejected +//! constructs are: +//! +//! - literal strings (`'…'`) and multi-line strings (`"""…"""`, +//! `'''…'''`); +//! - arrays, inline tables, and arrays of tables (`[[…]]`); +//! - dotted keys and quoted keys; +//! - floats, booleans, date-times, hex/octal/binary integers, and +//! underscore-separated integers; +//! - duplicate keys and duplicate table headers (full TOML rejects +//! these too). +//! +//! # Rendering +//! +//! [`to_string`] emits the canonical shape the `toml` crate (0.8.23) +//! emitted for the manifest schema: top-level scalar pairs first, then +//! each table preceded by one blank line, fields in struct declaration +//! order, no indentation, trailing newline. One deliberate divergence: +//! a string needing escapes renders as a basic string with backslash +//! escapes, where the `toml` crate switched to a literal string — this +//! module's renderer never emits what its own parser cannot read. +//! Equivalence with `toml` 0.8.23 is pinned by the golden fixtures in +//! this module's tests. + +#![forbid(unsafe_code)] + +use serde::de::DeserializeOwned; +use serde::ser::{Impossible, Serialize, SerializeStruct, Serializer}; + +/// Deserialization: the error type of [`from_str`]. +pub(crate) mod de { + /// A parse or schema error, mirroring `toml::de::Error`'s role. + /// Syntax errors carry the offending line; schema errors (unknown + /// field, wrong type) are line-less. + #[derive(Debug)] + pub struct Error { + pub(super) line: Option, + pub(super) message: String, + } + + impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.line { + Some(line) => write!( + f, + "TOML-subset parse error at line {line}: {}", + self.message + ), + None => write!(f, "{}", self.message), + } + } + } + + impl std::error::Error for Error {} +} + +/// Serialization: the error type of [`to_string`]. +pub(crate) mod ser { + /// A rendering error, mirroring `toml::ser::Error`'s role. + #[derive(Debug)] + pub struct Error(pub(super) String); + + impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + + impl std::error::Error for Error {} + + impl serde::ser::Error for Error { + fn custom(message: T) -> Self { + Error(message.to_string()) + } + } +} + +/// Parse a document of the manifest dialect into any deserializable +/// type. Interface-compatible with `toml::from_str`. +pub(crate) fn from_str(text: &str) -> Result { + let value = parse_document(text)?; + serde_json::from_value(value).map_err(|schema_error| de::Error { + line: None, + message: schema_error.to_string(), + }) +} + +/// Render a serializable value as a document of the manifest dialect. +/// Interface-compatible with `toml::to_string`. +pub(crate) fn to_string(value: &T) -> Result { + match value.serialize(NodeSerializer)? { + Node::Table(pairs) => render_document(&pairs), + Node::Scalar(_) => Err(ser::Error( + "only a table-shaped (struct) top-level value can be rendered".to_string(), + )), + } +} + +// --------------------------------------------------------------------------- +// Parsing: dialect text → `serde_json::Value` tree. The schema mapping +// (field names, types, unknown-field rejection) is then serde_json's +// job, so the schema stays defined once, on the derives. +// --------------------------------------------------------------------------- + +fn parse_document(text: &str) -> Result { + let mut root = serde_json::Map::new(); + let mut current_table: Option = None; + + for (index, raw_line) in text.lines().enumerate() { + let line = index + 1; + let error = |message: String| de::Error { + line: Some(line), + message, + }; + let rest = raw_line.trim_start(); + if rest.is_empty() || rest.starts_with('#') { + continue; + } + + if let Some(header) = rest.strip_prefix('[') { + if header.starts_with('[') { + return Err(error( + "arrays of tables (`[[…]]`) are not supported by the manifest dialect" + .to_string(), + )); + } + let (name, after_key) = take_bare_key(header.trim_start()) + .ok_or_else(|| error("expected a bare key as the table name".to_string()))?; + let after_key = after_key.trim_start(); + if let Some(after_bracket) = after_key.strip_prefix(']') { + require_only_trailing_comment(after_bracket).map_err(&error)?; + } else if after_key.starts_with('.') { + return Err(error(format!( + "dotted table name after `[{name}` — nested tables are not supported \ + by the manifest dialect" + ))); + } else { + return Err(error(format!("expected `]` to close the `[{name}` header"))); + } + if root.contains_key(&name) { + return Err(error(format!("table `[{name}]` is defined more than once"))); + } + root.insert( + name.clone(), + serde_json::Value::Object(serde_json::Map::new()), + ); + current_table = Some(name); + continue; + } + + let (key, after_key) = parse_key(rest).map_err(&error)?; + let after_equals = after_key + .trim_start() + .strip_prefix('=') + .ok_or_else(|| error(format!("expected `=` after key `{key}`")))?; + let (value, after_value) = parse_value(after_equals.trim_start()).map_err(&error)?; + require_only_trailing_comment(after_value).map_err(&error)?; + + let table = match ¤t_table { + Some(name) => root + .get_mut(name) + .and_then(serde_json::Value::as_object_mut) + .expect("the current table was inserted when its header was parsed"), + None => &mut root, + }; + if table.insert(key.clone(), value).is_some() { + return Err(error(format!("key `{key}` is set more than once"))); + } + } + + Ok(serde_json::Value::Object(root)) +} + +/// A bare key at the head of `rest`: the key and what follows it. +/// `None` when `rest` does not start with a bare-key character. +fn take_bare_key(rest: &str) -> Option<(String, &str)> { + let end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-')) + .unwrap_or(rest.len()); + (end > 0).then(|| (rest[..end].to_string(), &rest[end..])) +} + +/// The key position of a `key = value` line, rejecting the key forms +/// the dialect excludes. +fn parse_key(rest: &str) -> Result<(String, &str), String> { + if rest.starts_with('"') || rest.starts_with('\'') { + return Err("quoted keys are not supported by the manifest dialect; \ + use a bare key (letters, digits, `_`, `-`)" + .to_string()); + } + let (key, after_key) = + take_bare_key(rest).ok_or_else(|| format!("expected a bare key, found {rest:?}"))?; + if after_key.trim_start().starts_with('.') { + return Err(format!( + "dotted key after `{key}` — dotted keys are not supported by the manifest dialect; \ + put the key under a `[table]` header instead" + )); + } + Ok((key, after_key)) +} + +/// The value position of a `key = value` line: the parsed value and +/// what follows it. Rejects every value form outside the dialect with +/// an error naming the construct. +fn parse_value(rest: &str) -> Result<(serde_json::Value, &str), String> { + match rest.chars().next() { + Some('"') => { + if rest.starts_with("\"\"\"") { + return Err( + "multi-line strings (`\"\"\"…\"\"\"`) are not supported by the manifest \ + dialect" + .to_string(), + ); + } + let (string, after) = parse_basic_string(&rest[1..])?; + Ok((serde_json::Value::String(string), after)) + } + Some('\'') => Err( + "literal strings (`'…'`) are not supported by the manifest dialect; \ + use a basic `\"…\"` string with escapes" + .to_string(), + ), + Some('[') => Err("arrays are not supported by the manifest dialect".to_string()), + Some('{') => Err("inline tables are not supported by the manifest dialect".to_string()), + Some(_) => { + let end = rest + .find(|c: char| c.is_ascii_whitespace() || c == '#') + .unwrap_or(rest.len()); + let token = &rest[..end]; + Ok((parse_scalar_token(token)?, &rest[end..])) + } + None => Err("expected a value after `=`".to_string()), + } +} + +/// Classify an unquoted value token: a decimal integer, or a precise +/// rejection naming which unsupported TOML form it looks like. +fn parse_scalar_token(token: &str) -> Result { + let unsupported = |construct: &str| { + format!("{construct} are not supported by the manifest dialect (found {token:?})") + }; + if token == "true" || token == "false" { + return Err(unsupported("booleans")); + } + let digits = token.strip_prefix('-').unwrap_or(token); + if digits.starts_with("0x") || digits.starts_with("0o") || digits.starts_with("0b") { + return Err(unsupported("hex, octal, and binary integers")); + } + if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit() || c == '_') { + if digits.contains('_') { + return Err(unsupported("underscore-separated integers")); + } + let value: i64 = token + .parse() + .map_err(|_| format!("integer {token:?} does not fit in 64 bits"))?; + return Ok(serde_json::Value::from(value)); + } + let numeric_head = digits.chars().next().is_some_and(|c| c.is_ascii_digit()); + if numeric_head && (digits.contains('-') || digits.contains(':')) { + return Err(unsupported("date-times")); + } + if numeric_head || token.starts_with('+') || token == "inf" || token == "nan" { + return Err(unsupported("floats and non-decimal number forms")); + } + Err(format!("unrecognized value {token:?}")) +} + +/// The remainder of a basic string after its opening quote: the +/// unescaped content and what follows the closing quote. +fn parse_basic_string(rest: &str) -> Result<(String, &str), String> { + let mut content = String::new(); + let mut chars = rest.char_indices(); + while let Some((index, c)) = chars.next() { + match c { + '"' => return Ok((content, &rest[index + 1..])), + '\\' => content.push(parse_escape(&mut chars)?), + c if c != '\t' && (c.is_control()) => { + return Err(format!( + "control character {:?} in a basic string; escape it (e.g. `\\u{:04X}`)", + c, c as u32 + )); + } + c => content.push(c), + } + } + Err("unterminated basic string (the dialect confines strings to one line)".to_string()) +} + +/// One escape sequence, with the cursor just past the backslash. +fn parse_escape(chars: &mut std::str::CharIndices<'_>) -> Result { + let (_, escape) = chars + .next() + .ok_or_else(|| "dangling `\\` at end of string".to_string())?; + let hex_escape = |chars: &mut std::str::CharIndices<'_>, count: usize| { + let hex: String = chars.take(count).map(|(_, c)| c).collect(); + if hex.len() < count { + return Err(format!("`\\{escape}` expects {count} hex digits")); + } + let code = u32::from_str_radix(&hex, 16) + .map_err(|_| format!("`\\{escape}{hex}` is not a hex escape"))?; + char::from_u32(code).ok_or_else(|| format!("`\\{escape}{hex}` is not a Unicode scalar")) + }; + match escape { + 'b' => Ok('\u{8}'), + 't' => Ok('\t'), + 'n' => Ok('\n'), + 'f' => Ok('\u{c}'), + 'r' => Ok('\r'), + '"' => Ok('"'), + '\\' => Ok('\\'), + 'u' => hex_escape(chars, 4), + 'U' => hex_escape(chars, 8), + other => Err(format!("unknown escape `\\{other}` in a basic string")), + } +} + +/// After a value or table header, only whitespace or a comment may +/// remain on the line. +fn require_only_trailing_comment(rest: &str) -> Result<(), String> { + let rest = rest.trim_start(); + if rest.is_empty() || rest.starts_with('#') { + Ok(()) + } else { + Err(format!("unexpected trailing content {rest:?}")) + } +} + +// --------------------------------------------------------------------------- +// Rendering: a serde `Serializer` that collects the value into a +// one-or-two-level node tree (fields in declaration order), then a +// writer that emits the `toml`-crate-compatible shape. +// --------------------------------------------------------------------------- + +/// A rendered value: a ready-to-emit scalar, or a table of them. +enum Node { + Scalar(String), + Table(Vec<(String, Node)>), +} + +/// Serializes scalars and structs into [`Node`]s; everything the +/// dialect cannot express is an error. +struct NodeSerializer; + +impl NodeSerializer { + fn scalar(text: String) -> Result { + Ok(Node::Scalar(text)) + } + + fn unsupported(what: &str) -> ser::Error { + ser::Error(format!("{what} cannot be rendered in the manifest dialect")) + } +} + +impl Serializer for NodeSerializer { + type Ok = Node; + type Error = ser::Error; + type SerializeSeq = Impossible; + type SerializeTuple = Impossible; + type SerializeTupleStruct = Impossible; + type SerializeTupleVariant = Impossible; + type SerializeMap = Impossible; + type SerializeStruct = TableCollector; + type SerializeStructVariant = Impossible; + + fn serialize_str(self, v: &str) -> Result { + Self::scalar(render_string(v)) + } + + fn serialize_char(self, v: char) -> Result { + Self::scalar(render_string(&v.to_string())) + } + + fn serialize_i8(self, v: i8) -> Result { + self.serialize_i64(v.into()) + } + + fn serialize_i16(self, v: i16) -> Result { + self.serialize_i64(v.into()) + } + + fn serialize_i32(self, v: i32) -> Result { + self.serialize_i64(v.into()) + } + + fn serialize_i64(self, v: i64) -> Result { + Self::scalar(v.to_string()) + } + + fn serialize_u8(self, v: u8) -> Result { + self.serialize_u64(v.into()) + } + + fn serialize_u16(self, v: u16) -> Result { + self.serialize_u64(v.into()) + } + + fn serialize_u32(self, v: u32) -> Result { + self.serialize_u64(v.into()) + } + + fn serialize_u64(self, v: u64) -> Result { + i64::try_from(v) + .map_err(|_| ser::Error(format!("integer {v} does not fit in TOML's 64-bit range")))?; + Self::scalar(v.to_string()) + } + + fn serialize_bool(self, _: bool) -> Result { + Err(Self::unsupported("booleans")) + } + + fn serialize_f32(self, _: f32) -> Result { + Err(Self::unsupported("floats")) + } + + fn serialize_f64(self, _: f64) -> Result { + Err(Self::unsupported("floats")) + } + + fn serialize_bytes(self, _: &[u8]) -> Result { + Err(Self::unsupported("byte arrays")) + } + + fn serialize_none(self) -> Result { + Err(Self::unsupported( + "`None` (mark the field `skip_serializing_if = \"Option::is_none\"`)", + )) + } + + fn serialize_some(self, value: &T) -> Result { + value.serialize(self) + } + + fn serialize_unit(self) -> Result { + Err(Self::unsupported("unit values")) + } + + fn serialize_unit_struct(self, _: &'static str) -> Result { + Err(Self::unsupported("unit structs")) + } + + fn serialize_unit_variant( + self, + _: &'static str, + _: u32, + variant: &'static str, + ) -> Result { + self.serialize_str(variant) + } + + fn serialize_newtype_struct( + self, + _: &'static str, + value: &T, + ) -> Result { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: &T, + ) -> Result { + Err(Self::unsupported("enum variants with data")) + } + + fn serialize_seq(self, _: Option) -> Result { + Err(Self::unsupported("sequences")) + } + + fn serialize_tuple(self, _: usize) -> Result { + Err(Self::unsupported("tuples")) + } + + fn serialize_tuple_struct( + self, + _: &'static str, + _: usize, + ) -> Result { + Err(Self::unsupported("tuple structs")) + } + + fn serialize_tuple_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: usize, + ) -> Result { + Err(Self::unsupported("tuple variants")) + } + + fn serialize_map(self, _: Option) -> Result { + Err(Self::unsupported("maps (use a struct)")) + } + + fn serialize_struct( + self, + _: &'static str, + len: usize, + ) -> Result { + Ok(TableCollector { + pairs: Vec::with_capacity(len), + }) + } + + fn serialize_struct_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: usize, + ) -> Result { + Err(Self::unsupported("struct variants")) + } +} + +/// Collects one struct's fields, in declaration order, into a +/// [`Node::Table`]. +struct TableCollector { + pairs: Vec<(String, Node)>, +} + +impl SerializeStruct for TableCollector { + type Ok = Node; + type Error = ser::Error; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), ser::Error> { + self.pairs + .push((key.to_string(), value.serialize(NodeSerializer)?)); + Ok(()) + } + + fn end(self) -> Result { + Ok(Node::Table(self.pairs)) + } +} + +/// Emit the document: top-level scalars first (TOML requires them +/// before the first table header), then each table preceded by one +/// blank line — the shape `toml` 0.8.23 emitted. +fn render_document(pairs: &[(String, Node)]) -> Result { + let mut out = String::new(); + for (key, node) in pairs { + if let Node::Scalar(text) = node { + render_pair(&mut out, key, text)?; + } + } + for (key, node) in pairs { + let Node::Table(fields) = node else { continue }; + if !out.is_empty() { + out.push('\n'); + } + out.push('['); + out.push_str(bare_key(key)?); + out.push_str("]\n"); + for (field_key, field_node) in fields { + match field_node { + Node::Scalar(text) => render_pair(&mut out, field_key, text)?, + Node::Table(_) => { + return Err(ser::Error(format!( + "table `{key}.{field_key}` nests below the first level, which the \ + manifest dialect cannot express" + ))); + } + } + } + } + Ok(out) +} + +fn render_pair(out: &mut String, key: &str, value: &str) -> Result<(), ser::Error> { + out.push_str(bare_key(key)?); + out.push_str(" = "); + out.push_str(value); + out.push('\n'); + Ok(()) +} + +/// Keys render bare; a key the dialect could not parse back is an +/// error rather than silently invalid output. +fn bare_key(key: &str) -> Result<&str, ser::Error> { + let bare = !key.is_empty() + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + bare.then_some(key) + .ok_or_else(|| ser::Error(format!("key {key:?} cannot be rendered as a bare TOML key"))) +} + +/// A basic string with the dialect's escapes. Where the `toml` crate +/// would switch to a literal string, this stays basic-with-escapes so +/// the output always re-parses under [`from_str`]. +fn render_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\t' => out.push_str("\\t"), + '\n' => out.push_str("\\n"), + '\u{c}' => out.push_str("\\f"), + '\r' => out.push_str("\\r"), + c if c.is_control() => { + out.push_str(&format!("\\u{:04X}", c as u32)); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + /// Manifest-shaped structs local to these tests: the module is + /// generic over serde, so its tests do not reach into the real + /// schema in `container::manifest`. + #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] + #[serde(deny_unknown_fields)] + struct Artifact { + #[serde(skip_serializing_if = "Option::is_none")] + image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + track: Option, + #[serde(skip_serializing_if = "Option::is_none")] + local: Option, + #[serde(skip_serializing_if = "Option::is_none")] + entrypoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pull: Option, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] + #[serde(deny_unknown_fields)] + struct Manifest { + version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + runtime: Option, + #[serde(skip_serializing_if = "Option::is_none")] + validator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + indexer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + wallet: Option, + } + + /// Golden equivalence corpus. Each right-hand string is the exact + /// output `toml = "0.8.23"` produced for the left-hand value, + /// captured on 2026-07-11 immediately before the dependency was + /// removed; a live A/B test at that commit asserted this module's + /// renderer matched it byte for byte, and its parser read every + /// fixture to the same value as the crate. These fixtures freeze + /// that proof so it keeps running offline. + fn golden_corpus() -> Vec<(Manifest, &'static str)> { + vec![ + ( + Manifest { + version: 1, + runtime: None, + validator: None, + indexer: None, + wallet: None, + }, + "version = 1\n", + ), + ( + Manifest { + version: 1, + runtime: Some("docker".into()), + validator: Some(Artifact { + image: Some("zfnd/zebra:v6.0.0@sha256:aaaa".into()), + track: Some("zfnd/zebra:latest".into()), + entrypoint: Some("zebrad".into()), + pull: Some("if-missing".into()), + ..Default::default() + }), + indexer: Some(Artifact { + local: Some("/builds/zainod".into()), + ..Default::default() + }), + wallet: Some(Artifact::default()), + }, + "version = 1\n\ + runtime = \"docker\"\n\ + \n\ + [validator]\n\ + image = \"zfnd/zebra:v6.0.0@sha256:aaaa\"\n\ + track = \"zfnd/zebra:latest\"\n\ + entrypoint = \"zebrad\"\n\ + pull = \"if-missing\"\n\ + \n\ + [indexer]\n\ + local = \"/builds/zainod\"\n\ + \n\ + [wallet]\n", + ), + ( + Manifest { + version: 42, + runtime: Some("podman".into()), + validator: None, + indexer: None, + wallet: Some(Artifact { + pull: Some("never".into()), + ..Default::default() + }), + }, + "version = 42\n\ + runtime = \"podman\"\n\ + \n\ + [wallet]\n\ + pull = \"never\"\n", + ), + ] + } + + #[test] + fn renders_the_toml_crates_exact_bytes() { + for (manifest, golden) in golden_corpus() { + assert_eq!(super::to_string(&manifest).unwrap(), golden, "{manifest:?}"); + } + } + + #[test] + fn parses_the_toml_crates_output_back_to_the_value() { + for (manifest, golden) in golden_corpus() { + let parsed: Manifest = super::from_str(golden).unwrap(); + assert_eq!(parsed, manifest, "{golden}"); + } + } + + /// Dialect features the golden corpus does not exercise: + /// indentation, trailing comments, `#` inside strings, and every + /// escape form. The expected values were verified against + /// `toml = "0.8.23"` in the same live A/B run as the goldens. + #[test] + fn parses_comments_indentation_and_escapes() { + let manifest: Manifest = super::from_str( + r#" + version = 1 # trailing comment + runtime = "a#not-comment" + + # whole-line comment + [validator] + entrypoint = "tab\there A A \U0001F980 quote\" back\\slash" # after string + "#, + ) + .unwrap(); + assert_eq!(manifest.version, 1); + assert_eq!(manifest.runtime.as_deref(), Some("a#not-comment")); + assert_eq!( + manifest.validator.unwrap().entrypoint.as_deref(), + Some("tab\there A A \u{1F980} quote\" back\\slash"), + ); + } + + #[test] + fn parses_negative_integers() { + #[derive(Deserialize)] + struct Signed { + offset: i64, + } + let signed: Signed = super::from_str("offset = -3\n").unwrap(); + assert_eq!(signed.offset, -3); + } + + /// The one deliberate rendering divergence from the `toml` crate: + /// a string needing escapes stays a basic string (the crate + /// switched to a literal string, `'/builds/back\slash "quoted"'`, + /// which this dialect cannot read back). Semantic equivalence is + /// witnessed by the round trip. + #[test] + fn escape_needing_strings_render_basic_and_round_trip() { + let manifest = Manifest { + version: 1, + runtime: Some("back\\slash \"quoted\" tab\t crab\u{1F980}".into()), + validator: None, + indexer: None, + wallet: None, + }; + let rendered = super::to_string(&manifest).unwrap(); + assert_eq!( + rendered, + "version = 1\nruntime = \"back\\\\slash \\\"quoted\\\" tab\\t crab\u{1F980}\"\n", + ); + let round_tripped: Manifest = super::from_str(&rendered).unwrap(); + assert_eq!(round_tripped, manifest); + } + + #[test] + fn round_trips_the_whole_corpus() { + for (manifest, _) in golden_corpus() { + let round_tripped: Manifest = + super::from_str(&super::to_string(&manifest).unwrap()).unwrap(); + assert_eq!(round_tripped, manifest); + } + } + + /// Every construct outside the dialect dies with an error naming + /// the construct and its line. + #[test] + fn rejects_each_unsupported_construct_by_name_and_line() { + let rejections = [ + ("version = 1\nlocal = 'literal'\n", "literal strings", 2), + ( + "version = 1\ndesc = \"\"\"m\"\"\"\n", + "multi-line strings", + 2, + ), + ("version = 1\nlist = [1, 2]\n", "arrays", 2), + ("version = 1\ntbl = { a = 1 }\n", "inline tables", 2), + ("version = 1\n[[wallet]]\n", "arrays of tables", 2), + ("version = 1\nvalidator.image = \"z:1\"\n", "dotted keys", 2), + ("version = 1\n\"quoted\" = 1\n", "quoted keys", 2), + ("version = 1\nratio = 1.5\n", "floats", 2), + ("version = 1\nflag = true\n", "booleans", 2), + ("version = 1\nwhen = 1979-05-27\n", "date-times", 2), + ( + "version = 1\nmask = 0xff\n", + "hex, octal, and binary integers", + 2, + ), + ( + "version = 1\nbig = 1_000\n", + "underscore-separated integers", + 2, + ), + ("version = 1\n[validator.env]\n", "nested tables", 2), + ]; + for (text, construct, line) in rejections { + let error = super::from_str::(text).unwrap_err().to_string(); + assert!(error.contains(construct), "{text:?} -> {error}"); + assert!( + error.contains(&format!("line {line}")), + "{text:?} -> {error}" + ); + } + } + + #[test] + fn rejects_duplicates_and_malformed_lines() { + let errors = [ + ("version = 1\nversion = 2\n", "more than once"), + ("version = 1\n[wallet]\n[wallet]\n", "more than once"), + ("version = 1\nrun = \"unterminated\n", "unterminated"), + ("version = 1\nrun = \"bad \\q escape\"\n", "unknown escape"), + ("version = 1\nrun = \"a\" junk\n", "trailing content"), + ("version = 1\nnovalue =\n", "expected a value"), + ("version = 1\nkeyonly\n", "expected `=`"), + ]; + for (text, needle) in errors { + let error = super::from_str::(text).unwrap_err().to_string(); + assert!(error.contains(needle), "{text:?} -> {error}"); + } + } + + /// Schema-level failures surface serde's own prose (no line + /// numbers — the document parsed; the shape was wrong). + #[test] + fn schema_errors_pass_through_serde() { + let unknown = super::from_str::("version = 1\nbogus = 2\n") + .unwrap_err() + .to_string(); + assert!(unknown.contains("unknown field `bogus`"), "{unknown}"); + let wrong_type = super::from_str::("version = \"1\"\n") + .unwrap_err() + .to_string(); + assert!(wrong_type.contains("invalid type"), "{wrong_type}"); + } + + /// Values the dialect cannot express fail to render rather than + /// producing unparseable output. + #[test] + fn rendering_rejects_what_the_dialect_cannot_express() { + #[derive(Serialize)] + struct Flag { + flag: bool, + } + let error = super::to_string(&Flag { flag: true }) + .unwrap_err() + .to_string(); + assert!(error.contains("booleans"), "{error}"); + } +} From 3b2d8ed50949d48a026149321da1b36f400a4468 Mon Sep 17 00:00:00 2001 From: zancas Date: Sat, 11 Jul 2026 13:15:02 -0700 Subject: [PATCH 6/6] Revert "refactor(zcash_local_net): replace the toml crate with an in-repo manifest-dialect module" This reverts commit 14750cb8ba9c89fb2a5a37067b4dabdff2d5710e. --- CONTEXT.md | 8 - Cargo.lock | 82 ++ zcash_local_net/Cargo.toml | 1 + zcash_local_net/src/container/manifest.rs | 7 +- zcash_local_net/src/container/update.rs | 4 +- zcash_local_net/src/lib.rs | 1 - zcash_local_net/src/toml.rs | 915 ---------------------- 7 files changed, 86 insertions(+), 932 deletions(-) delete mode 100644 zcash_local_net/src/toml.rs diff --git a/CONTEXT.md b/CONTEXT.md index e806c74..de8edbc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -91,11 +91,3 @@ heights. The Validator is configured with heights exactly once, at launch; the Indexer and the wallet client derive theirs from it, and supplying heights to those components by hand is unrepresentable. _Avoid_: provider heights, custom heights, caller heights - -**Manifest dialect**: -The deliberately minimal subset of TOML that artifact manifests are -written in: comments, bare keys, tables, basic strings, and decimal -integers. A manifest is either inside the dialect or rejected loudly at -load time — no construct outside it is ever silently misread. Full TOML -is not the contract. -_Avoid_: full TOML (as a description of what manifests may contain) diff --git a/Cargo.lock b/Cargo.lock index 3d8f702..0b1955f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,12 @@ dependencies = [ "syn", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -283,6 +289,12 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -480,6 +492,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -783,6 +805,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -969,6 +1000,47 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -1220,6 +1292,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "workbench" version = "0.1.0" @@ -1268,6 +1349,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "toml", "tracing", "tracing-subscriber", "zingo-consensus", diff --git a/zcash_local_net/Cargo.toml b/zcash_local_net/Cargo.toml index 01e9036..8e47cc8 100644 --- a/zcash_local_net/Cargo.toml +++ b/zcash_local_net/Cargo.toml @@ -35,6 +35,7 @@ hex = { workspace = true } tokio = { workspace = true, features = ["io-util", "net", "rt", "time", "process"] } serde = { version = "1.0", features = ["derive"] } sha2 = "0.10" +toml = "0.8" [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "io-util"] } diff --git a/zcash_local_net/src/container/manifest.rs b/zcash_local_net/src/container/manifest.rs index 16e16c7..3a3a897 100644 --- a/zcash_local_net/src/container/manifest.rs +++ b/zcash_local_net/src/container/manifest.rs @@ -11,10 +11,7 @@ //! The format is TOML — the configuration language everything else in //! this ecosystem already speaks (`zebrad.toml`, `zindexer.toml`, //! `Cargo.toml`), comments included. `zcash-local-net template` -//! prints a starting point. Manifests are parsed by a deliberately -//! minimal in-repo subset of TOML, not a full TOML parser; the -//! crate-internal `toml` module documents the exact dialect, and -//! rejects — loudly, never silently — every construct outside it. +//! prints a starting point. //! //! ```toml //! version = 1 @@ -81,7 +78,7 @@ use serde::Deserialize; use super::{ArtifactSource, ContainerImage, ContainerRuntime, PullPolicy}; use crate::{ - LocalNet, error::LaunchError, indexer::zainod::Zainod, indexer::zainod::ZainodConfig, toml, + LocalNet, error::LaunchError, indexer::zainod::Zainod, indexer::zainod::ZainodConfig, validator::zebrad::Zebrad, validator::zebrad::ZebradConfig, }; diff --git a/zcash_local_net/src/container/update.rs b/zcash_local_net/src/container/update.rs index 6163668..1f5ecba 100644 --- a/zcash_local_net/src/container/update.rs +++ b/zcash_local_net/src/container/update.rs @@ -22,7 +22,6 @@ use std::path::{Path, PathBuf}; use super::ContainerRuntime; use super::manifest::{ArtifactManifest, ManifestError}; -use crate::toml; /// The outcome of updating one container artifact. #[derive(Clone, Debug)] @@ -86,8 +85,7 @@ pub enum UpdateError { /// unchanged ones, so callers can report "already current". /// /// The file is rewritten in the manifest's canonical formatting -/// (fields in schema declaration order, one blank line before each -/// artifact table, no indentation). Host-process artifacts and +/// (stable field order, two-space indent). Host-process artifacts and /// digest-only images without `track` are untouched and produce no /// bump entry. pub async fn update_manifest_file( diff --git a/zcash_local_net/src/lib.rs b/zcash_local_net/src/lib.rs index df513d3..d0163a0 100644 --- a/zcash_local_net/src/lib.rs +++ b/zcash_local_net/src/lib.rs @@ -84,7 +84,6 @@ mod backend; mod launch; mod macros; mod poll; -mod toml; use indexer::Indexer; use validator::Validator; diff --git a/zcash_local_net/src/toml.rs b/zcash_local_net/src/toml.rs deleted file mode 100644 index 0b049af..0000000 --- a/zcash_local_net/src/toml.rs +++ /dev/null @@ -1,915 +0,0 @@ -//! A crate-internal, minimal TOML-subset parser and renderer. -//! -//! This module is a drop-in replacement for the two entry points this -//! crate used from the `toml` crate — [`from_str`] and [`to_string`], -//! with the crate's `de::Error` / `ser::Error` split — implemented -//! in-repo so the manifest machinery carries no TOML dependency. -//! -//! # This is NOT a full TOML parser -//! -//! It deliberately implements only the minimal dialect the artifact -//! manifest needs. A document is accepted when it uses nothing but: -//! -//! - comments (`# …`), whole-line or trailing a value, and blank lines; -//! - bare keys (ASCII letters, digits, `_`, `-`); -//! - table headers (`[validator]`) naming a single bare key, each -//! header appearing at most once; -//! - basic strings (`"…"`) confined to one line, with the standard -//! TOML escapes (`\b \t \n \f \r \" \\ \uXXXX \UXXXXXXXX`); -//! - decimal integers (optional leading `-`, no underscores). -//! -//! Every other TOML construct is rejected with an error naming the -//! construct and its line — never silently misread. The rejected -//! constructs are: -//! -//! - literal strings (`'…'`) and multi-line strings (`"""…"""`, -//! `'''…'''`); -//! - arrays, inline tables, and arrays of tables (`[[…]]`); -//! - dotted keys and quoted keys; -//! - floats, booleans, date-times, hex/octal/binary integers, and -//! underscore-separated integers; -//! - duplicate keys and duplicate table headers (full TOML rejects -//! these too). -//! -//! # Rendering -//! -//! [`to_string`] emits the canonical shape the `toml` crate (0.8.23) -//! emitted for the manifest schema: top-level scalar pairs first, then -//! each table preceded by one blank line, fields in struct declaration -//! order, no indentation, trailing newline. One deliberate divergence: -//! a string needing escapes renders as a basic string with backslash -//! escapes, where the `toml` crate switched to a literal string — this -//! module's renderer never emits what its own parser cannot read. -//! Equivalence with `toml` 0.8.23 is pinned by the golden fixtures in -//! this module's tests. - -#![forbid(unsafe_code)] - -use serde::de::DeserializeOwned; -use serde::ser::{Impossible, Serialize, SerializeStruct, Serializer}; - -/// Deserialization: the error type of [`from_str`]. -pub(crate) mod de { - /// A parse or schema error, mirroring `toml::de::Error`'s role. - /// Syntax errors carry the offending line; schema errors (unknown - /// field, wrong type) are line-less. - #[derive(Debug)] - pub struct Error { - pub(super) line: Option, - pub(super) message: String, - } - - impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.line { - Some(line) => write!( - f, - "TOML-subset parse error at line {line}: {}", - self.message - ), - None => write!(f, "{}", self.message), - } - } - } - - impl std::error::Error for Error {} -} - -/// Serialization: the error type of [`to_string`]. -pub(crate) mod ser { - /// A rendering error, mirroring `toml::ser::Error`'s role. - #[derive(Debug)] - pub struct Error(pub(super) String); - - impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } - } - - impl std::error::Error for Error {} - - impl serde::ser::Error for Error { - fn custom(message: T) -> Self { - Error(message.to_string()) - } - } -} - -/// Parse a document of the manifest dialect into any deserializable -/// type. Interface-compatible with `toml::from_str`. -pub(crate) fn from_str(text: &str) -> Result { - let value = parse_document(text)?; - serde_json::from_value(value).map_err(|schema_error| de::Error { - line: None, - message: schema_error.to_string(), - }) -} - -/// Render a serializable value as a document of the manifest dialect. -/// Interface-compatible with `toml::to_string`. -pub(crate) fn to_string(value: &T) -> Result { - match value.serialize(NodeSerializer)? { - Node::Table(pairs) => render_document(&pairs), - Node::Scalar(_) => Err(ser::Error( - "only a table-shaped (struct) top-level value can be rendered".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// Parsing: dialect text → `serde_json::Value` tree. The schema mapping -// (field names, types, unknown-field rejection) is then serde_json's -// job, so the schema stays defined once, on the derives. -// --------------------------------------------------------------------------- - -fn parse_document(text: &str) -> Result { - let mut root = serde_json::Map::new(); - let mut current_table: Option = None; - - for (index, raw_line) in text.lines().enumerate() { - let line = index + 1; - let error = |message: String| de::Error { - line: Some(line), - message, - }; - let rest = raw_line.trim_start(); - if rest.is_empty() || rest.starts_with('#') { - continue; - } - - if let Some(header) = rest.strip_prefix('[') { - if header.starts_with('[') { - return Err(error( - "arrays of tables (`[[…]]`) are not supported by the manifest dialect" - .to_string(), - )); - } - let (name, after_key) = take_bare_key(header.trim_start()) - .ok_or_else(|| error("expected a bare key as the table name".to_string()))?; - let after_key = after_key.trim_start(); - if let Some(after_bracket) = after_key.strip_prefix(']') { - require_only_trailing_comment(after_bracket).map_err(&error)?; - } else if after_key.starts_with('.') { - return Err(error(format!( - "dotted table name after `[{name}` — nested tables are not supported \ - by the manifest dialect" - ))); - } else { - return Err(error(format!("expected `]` to close the `[{name}` header"))); - } - if root.contains_key(&name) { - return Err(error(format!("table `[{name}]` is defined more than once"))); - } - root.insert( - name.clone(), - serde_json::Value::Object(serde_json::Map::new()), - ); - current_table = Some(name); - continue; - } - - let (key, after_key) = parse_key(rest).map_err(&error)?; - let after_equals = after_key - .trim_start() - .strip_prefix('=') - .ok_or_else(|| error(format!("expected `=` after key `{key}`")))?; - let (value, after_value) = parse_value(after_equals.trim_start()).map_err(&error)?; - require_only_trailing_comment(after_value).map_err(&error)?; - - let table = match ¤t_table { - Some(name) => root - .get_mut(name) - .and_then(serde_json::Value::as_object_mut) - .expect("the current table was inserted when its header was parsed"), - None => &mut root, - }; - if table.insert(key.clone(), value).is_some() { - return Err(error(format!("key `{key}` is set more than once"))); - } - } - - Ok(serde_json::Value::Object(root)) -} - -/// A bare key at the head of `rest`: the key and what follows it. -/// `None` when `rest` does not start with a bare-key character. -fn take_bare_key(rest: &str) -> Option<(String, &str)> { - let end = rest - .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-')) - .unwrap_or(rest.len()); - (end > 0).then(|| (rest[..end].to_string(), &rest[end..])) -} - -/// The key position of a `key = value` line, rejecting the key forms -/// the dialect excludes. -fn parse_key(rest: &str) -> Result<(String, &str), String> { - if rest.starts_with('"') || rest.starts_with('\'') { - return Err("quoted keys are not supported by the manifest dialect; \ - use a bare key (letters, digits, `_`, `-`)" - .to_string()); - } - let (key, after_key) = - take_bare_key(rest).ok_or_else(|| format!("expected a bare key, found {rest:?}"))?; - if after_key.trim_start().starts_with('.') { - return Err(format!( - "dotted key after `{key}` — dotted keys are not supported by the manifest dialect; \ - put the key under a `[table]` header instead" - )); - } - Ok((key, after_key)) -} - -/// The value position of a `key = value` line: the parsed value and -/// what follows it. Rejects every value form outside the dialect with -/// an error naming the construct. -fn parse_value(rest: &str) -> Result<(serde_json::Value, &str), String> { - match rest.chars().next() { - Some('"') => { - if rest.starts_with("\"\"\"") { - return Err( - "multi-line strings (`\"\"\"…\"\"\"`) are not supported by the manifest \ - dialect" - .to_string(), - ); - } - let (string, after) = parse_basic_string(&rest[1..])?; - Ok((serde_json::Value::String(string), after)) - } - Some('\'') => Err( - "literal strings (`'…'`) are not supported by the manifest dialect; \ - use a basic `\"…\"` string with escapes" - .to_string(), - ), - Some('[') => Err("arrays are not supported by the manifest dialect".to_string()), - Some('{') => Err("inline tables are not supported by the manifest dialect".to_string()), - Some(_) => { - let end = rest - .find(|c: char| c.is_ascii_whitespace() || c == '#') - .unwrap_or(rest.len()); - let token = &rest[..end]; - Ok((parse_scalar_token(token)?, &rest[end..])) - } - None => Err("expected a value after `=`".to_string()), - } -} - -/// Classify an unquoted value token: a decimal integer, or a precise -/// rejection naming which unsupported TOML form it looks like. -fn parse_scalar_token(token: &str) -> Result { - let unsupported = |construct: &str| { - format!("{construct} are not supported by the manifest dialect (found {token:?})") - }; - if token == "true" || token == "false" { - return Err(unsupported("booleans")); - } - let digits = token.strip_prefix('-').unwrap_or(token); - if digits.starts_with("0x") || digits.starts_with("0o") || digits.starts_with("0b") { - return Err(unsupported("hex, octal, and binary integers")); - } - if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit() || c == '_') { - if digits.contains('_') { - return Err(unsupported("underscore-separated integers")); - } - let value: i64 = token - .parse() - .map_err(|_| format!("integer {token:?} does not fit in 64 bits"))?; - return Ok(serde_json::Value::from(value)); - } - let numeric_head = digits.chars().next().is_some_and(|c| c.is_ascii_digit()); - if numeric_head && (digits.contains('-') || digits.contains(':')) { - return Err(unsupported("date-times")); - } - if numeric_head || token.starts_with('+') || token == "inf" || token == "nan" { - return Err(unsupported("floats and non-decimal number forms")); - } - Err(format!("unrecognized value {token:?}")) -} - -/// The remainder of a basic string after its opening quote: the -/// unescaped content and what follows the closing quote. -fn parse_basic_string(rest: &str) -> Result<(String, &str), String> { - let mut content = String::new(); - let mut chars = rest.char_indices(); - while let Some((index, c)) = chars.next() { - match c { - '"' => return Ok((content, &rest[index + 1..])), - '\\' => content.push(parse_escape(&mut chars)?), - c if c != '\t' && (c.is_control()) => { - return Err(format!( - "control character {:?} in a basic string; escape it (e.g. `\\u{:04X}`)", - c, c as u32 - )); - } - c => content.push(c), - } - } - Err("unterminated basic string (the dialect confines strings to one line)".to_string()) -} - -/// One escape sequence, with the cursor just past the backslash. -fn parse_escape(chars: &mut std::str::CharIndices<'_>) -> Result { - let (_, escape) = chars - .next() - .ok_or_else(|| "dangling `\\` at end of string".to_string())?; - let hex_escape = |chars: &mut std::str::CharIndices<'_>, count: usize| { - let hex: String = chars.take(count).map(|(_, c)| c).collect(); - if hex.len() < count { - return Err(format!("`\\{escape}` expects {count} hex digits")); - } - let code = u32::from_str_radix(&hex, 16) - .map_err(|_| format!("`\\{escape}{hex}` is not a hex escape"))?; - char::from_u32(code).ok_or_else(|| format!("`\\{escape}{hex}` is not a Unicode scalar")) - }; - match escape { - 'b' => Ok('\u{8}'), - 't' => Ok('\t'), - 'n' => Ok('\n'), - 'f' => Ok('\u{c}'), - 'r' => Ok('\r'), - '"' => Ok('"'), - '\\' => Ok('\\'), - 'u' => hex_escape(chars, 4), - 'U' => hex_escape(chars, 8), - other => Err(format!("unknown escape `\\{other}` in a basic string")), - } -} - -/// After a value or table header, only whitespace or a comment may -/// remain on the line. -fn require_only_trailing_comment(rest: &str) -> Result<(), String> { - let rest = rest.trim_start(); - if rest.is_empty() || rest.starts_with('#') { - Ok(()) - } else { - Err(format!("unexpected trailing content {rest:?}")) - } -} - -// --------------------------------------------------------------------------- -// Rendering: a serde `Serializer` that collects the value into a -// one-or-two-level node tree (fields in declaration order), then a -// writer that emits the `toml`-crate-compatible shape. -// --------------------------------------------------------------------------- - -/// A rendered value: a ready-to-emit scalar, or a table of them. -enum Node { - Scalar(String), - Table(Vec<(String, Node)>), -} - -/// Serializes scalars and structs into [`Node`]s; everything the -/// dialect cannot express is an error. -struct NodeSerializer; - -impl NodeSerializer { - fn scalar(text: String) -> Result { - Ok(Node::Scalar(text)) - } - - fn unsupported(what: &str) -> ser::Error { - ser::Error(format!("{what} cannot be rendered in the manifest dialect")) - } -} - -impl Serializer for NodeSerializer { - type Ok = Node; - type Error = ser::Error; - type SerializeSeq = Impossible; - type SerializeTuple = Impossible; - type SerializeTupleStruct = Impossible; - type SerializeTupleVariant = Impossible; - type SerializeMap = Impossible; - type SerializeStruct = TableCollector; - type SerializeStructVariant = Impossible; - - fn serialize_str(self, v: &str) -> Result { - Self::scalar(render_string(v)) - } - - fn serialize_char(self, v: char) -> Result { - Self::scalar(render_string(&v.to_string())) - } - - fn serialize_i8(self, v: i8) -> Result { - self.serialize_i64(v.into()) - } - - fn serialize_i16(self, v: i16) -> Result { - self.serialize_i64(v.into()) - } - - fn serialize_i32(self, v: i32) -> Result { - self.serialize_i64(v.into()) - } - - fn serialize_i64(self, v: i64) -> Result { - Self::scalar(v.to_string()) - } - - fn serialize_u8(self, v: u8) -> Result { - self.serialize_u64(v.into()) - } - - fn serialize_u16(self, v: u16) -> Result { - self.serialize_u64(v.into()) - } - - fn serialize_u32(self, v: u32) -> Result { - self.serialize_u64(v.into()) - } - - fn serialize_u64(self, v: u64) -> Result { - i64::try_from(v) - .map_err(|_| ser::Error(format!("integer {v} does not fit in TOML's 64-bit range")))?; - Self::scalar(v.to_string()) - } - - fn serialize_bool(self, _: bool) -> Result { - Err(Self::unsupported("booleans")) - } - - fn serialize_f32(self, _: f32) -> Result { - Err(Self::unsupported("floats")) - } - - fn serialize_f64(self, _: f64) -> Result { - Err(Self::unsupported("floats")) - } - - fn serialize_bytes(self, _: &[u8]) -> Result { - Err(Self::unsupported("byte arrays")) - } - - fn serialize_none(self) -> Result { - Err(Self::unsupported( - "`None` (mark the field `skip_serializing_if = \"Option::is_none\"`)", - )) - } - - fn serialize_some(self, value: &T) -> Result { - value.serialize(self) - } - - fn serialize_unit(self) -> Result { - Err(Self::unsupported("unit values")) - } - - fn serialize_unit_struct(self, _: &'static str) -> Result { - Err(Self::unsupported("unit structs")) - } - - fn serialize_unit_variant( - self, - _: &'static str, - _: u32, - variant: &'static str, - ) -> Result { - self.serialize_str(variant) - } - - fn serialize_newtype_struct( - self, - _: &'static str, - value: &T, - ) -> Result { - value.serialize(self) - } - - fn serialize_newtype_variant( - self, - _: &'static str, - _: u32, - _: &'static str, - _: &T, - ) -> Result { - Err(Self::unsupported("enum variants with data")) - } - - fn serialize_seq(self, _: Option) -> Result { - Err(Self::unsupported("sequences")) - } - - fn serialize_tuple(self, _: usize) -> Result { - Err(Self::unsupported("tuples")) - } - - fn serialize_tuple_struct( - self, - _: &'static str, - _: usize, - ) -> Result { - Err(Self::unsupported("tuple structs")) - } - - fn serialize_tuple_variant( - self, - _: &'static str, - _: u32, - _: &'static str, - _: usize, - ) -> Result { - Err(Self::unsupported("tuple variants")) - } - - fn serialize_map(self, _: Option) -> Result { - Err(Self::unsupported("maps (use a struct)")) - } - - fn serialize_struct( - self, - _: &'static str, - len: usize, - ) -> Result { - Ok(TableCollector { - pairs: Vec::with_capacity(len), - }) - } - - fn serialize_struct_variant( - self, - _: &'static str, - _: u32, - _: &'static str, - _: usize, - ) -> Result { - Err(Self::unsupported("struct variants")) - } -} - -/// Collects one struct's fields, in declaration order, into a -/// [`Node::Table`]. -struct TableCollector { - pairs: Vec<(String, Node)>, -} - -impl SerializeStruct for TableCollector { - type Ok = Node; - type Error = ser::Error; - - fn serialize_field( - &mut self, - key: &'static str, - value: &T, - ) -> Result<(), ser::Error> { - self.pairs - .push((key.to_string(), value.serialize(NodeSerializer)?)); - Ok(()) - } - - fn end(self) -> Result { - Ok(Node::Table(self.pairs)) - } -} - -/// Emit the document: top-level scalars first (TOML requires them -/// before the first table header), then each table preceded by one -/// blank line — the shape `toml` 0.8.23 emitted. -fn render_document(pairs: &[(String, Node)]) -> Result { - let mut out = String::new(); - for (key, node) in pairs { - if let Node::Scalar(text) = node { - render_pair(&mut out, key, text)?; - } - } - for (key, node) in pairs { - let Node::Table(fields) = node else { continue }; - if !out.is_empty() { - out.push('\n'); - } - out.push('['); - out.push_str(bare_key(key)?); - out.push_str("]\n"); - for (field_key, field_node) in fields { - match field_node { - Node::Scalar(text) => render_pair(&mut out, field_key, text)?, - Node::Table(_) => { - return Err(ser::Error(format!( - "table `{key}.{field_key}` nests below the first level, which the \ - manifest dialect cannot express" - ))); - } - } - } - } - Ok(out) -} - -fn render_pair(out: &mut String, key: &str, value: &str) -> Result<(), ser::Error> { - out.push_str(bare_key(key)?); - out.push_str(" = "); - out.push_str(value); - out.push('\n'); - Ok(()) -} - -/// Keys render bare; a key the dialect could not parse back is an -/// error rather than silently invalid output. -fn bare_key(key: &str) -> Result<&str, ser::Error> { - let bare = !key.is_empty() - && key - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); - bare.then_some(key) - .ok_or_else(|| ser::Error(format!("key {key:?} cannot be rendered as a bare TOML key"))) -} - -/// A basic string with the dialect's escapes. Where the `toml` crate -/// would switch to a literal string, this stays basic-with-escapes so -/// the output always re-parses under [`from_str`]. -fn render_string(value: &str) -> String { - let mut out = String::with_capacity(value.len() + 2); - out.push('"'); - for c in value.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\u{8}' => out.push_str("\\b"), - '\t' => out.push_str("\\t"), - '\n' => out.push_str("\\n"), - '\u{c}' => out.push_str("\\f"), - '\r' => out.push_str("\\r"), - c if c.is_control() => { - out.push_str(&format!("\\u{:04X}", c as u32)); - } - c => out.push(c), - } - } - out.push('"'); - out -} - -#[cfg(test)] -mod tests { - use serde::{Deserialize, Serialize}; - - /// Manifest-shaped structs local to these tests: the module is - /// generic over serde, so its tests do not reach into the real - /// schema in `container::manifest`. - #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] - #[serde(deny_unknown_fields)] - struct Artifact { - #[serde(skip_serializing_if = "Option::is_none")] - image: Option, - #[serde(skip_serializing_if = "Option::is_none")] - track: Option, - #[serde(skip_serializing_if = "Option::is_none")] - local: Option, - #[serde(skip_serializing_if = "Option::is_none")] - entrypoint: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pull: Option, - } - - #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] - #[serde(deny_unknown_fields)] - struct Manifest { - version: u32, - #[serde(skip_serializing_if = "Option::is_none")] - runtime: Option, - #[serde(skip_serializing_if = "Option::is_none")] - validator: Option, - #[serde(skip_serializing_if = "Option::is_none")] - indexer: Option, - #[serde(skip_serializing_if = "Option::is_none")] - wallet: Option, - } - - /// Golden equivalence corpus. Each right-hand string is the exact - /// output `toml = "0.8.23"` produced for the left-hand value, - /// captured on 2026-07-11 immediately before the dependency was - /// removed; a live A/B test at that commit asserted this module's - /// renderer matched it byte for byte, and its parser read every - /// fixture to the same value as the crate. These fixtures freeze - /// that proof so it keeps running offline. - fn golden_corpus() -> Vec<(Manifest, &'static str)> { - vec![ - ( - Manifest { - version: 1, - runtime: None, - validator: None, - indexer: None, - wallet: None, - }, - "version = 1\n", - ), - ( - Manifest { - version: 1, - runtime: Some("docker".into()), - validator: Some(Artifact { - image: Some("zfnd/zebra:v6.0.0@sha256:aaaa".into()), - track: Some("zfnd/zebra:latest".into()), - entrypoint: Some("zebrad".into()), - pull: Some("if-missing".into()), - ..Default::default() - }), - indexer: Some(Artifact { - local: Some("/builds/zainod".into()), - ..Default::default() - }), - wallet: Some(Artifact::default()), - }, - "version = 1\n\ - runtime = \"docker\"\n\ - \n\ - [validator]\n\ - image = \"zfnd/zebra:v6.0.0@sha256:aaaa\"\n\ - track = \"zfnd/zebra:latest\"\n\ - entrypoint = \"zebrad\"\n\ - pull = \"if-missing\"\n\ - \n\ - [indexer]\n\ - local = \"/builds/zainod\"\n\ - \n\ - [wallet]\n", - ), - ( - Manifest { - version: 42, - runtime: Some("podman".into()), - validator: None, - indexer: None, - wallet: Some(Artifact { - pull: Some("never".into()), - ..Default::default() - }), - }, - "version = 42\n\ - runtime = \"podman\"\n\ - \n\ - [wallet]\n\ - pull = \"never\"\n", - ), - ] - } - - #[test] - fn renders_the_toml_crates_exact_bytes() { - for (manifest, golden) in golden_corpus() { - assert_eq!(super::to_string(&manifest).unwrap(), golden, "{manifest:?}"); - } - } - - #[test] - fn parses_the_toml_crates_output_back_to_the_value() { - for (manifest, golden) in golden_corpus() { - let parsed: Manifest = super::from_str(golden).unwrap(); - assert_eq!(parsed, manifest, "{golden}"); - } - } - - /// Dialect features the golden corpus does not exercise: - /// indentation, trailing comments, `#` inside strings, and every - /// escape form. The expected values were verified against - /// `toml = "0.8.23"` in the same live A/B run as the goldens. - #[test] - fn parses_comments_indentation_and_escapes() { - let manifest: Manifest = super::from_str( - r#" - version = 1 # trailing comment - runtime = "a#not-comment" - - # whole-line comment - [validator] - entrypoint = "tab\there A A \U0001F980 quote\" back\\slash" # after string - "#, - ) - .unwrap(); - assert_eq!(manifest.version, 1); - assert_eq!(manifest.runtime.as_deref(), Some("a#not-comment")); - assert_eq!( - manifest.validator.unwrap().entrypoint.as_deref(), - Some("tab\there A A \u{1F980} quote\" back\\slash"), - ); - } - - #[test] - fn parses_negative_integers() { - #[derive(Deserialize)] - struct Signed { - offset: i64, - } - let signed: Signed = super::from_str("offset = -3\n").unwrap(); - assert_eq!(signed.offset, -3); - } - - /// The one deliberate rendering divergence from the `toml` crate: - /// a string needing escapes stays a basic string (the crate - /// switched to a literal string, `'/builds/back\slash "quoted"'`, - /// which this dialect cannot read back). Semantic equivalence is - /// witnessed by the round trip. - #[test] - fn escape_needing_strings_render_basic_and_round_trip() { - let manifest = Manifest { - version: 1, - runtime: Some("back\\slash \"quoted\" tab\t crab\u{1F980}".into()), - validator: None, - indexer: None, - wallet: None, - }; - let rendered = super::to_string(&manifest).unwrap(); - assert_eq!( - rendered, - "version = 1\nruntime = \"back\\\\slash \\\"quoted\\\" tab\\t crab\u{1F980}\"\n", - ); - let round_tripped: Manifest = super::from_str(&rendered).unwrap(); - assert_eq!(round_tripped, manifest); - } - - #[test] - fn round_trips_the_whole_corpus() { - for (manifest, _) in golden_corpus() { - let round_tripped: Manifest = - super::from_str(&super::to_string(&manifest).unwrap()).unwrap(); - assert_eq!(round_tripped, manifest); - } - } - - /// Every construct outside the dialect dies with an error naming - /// the construct and its line. - #[test] - fn rejects_each_unsupported_construct_by_name_and_line() { - let rejections = [ - ("version = 1\nlocal = 'literal'\n", "literal strings", 2), - ( - "version = 1\ndesc = \"\"\"m\"\"\"\n", - "multi-line strings", - 2, - ), - ("version = 1\nlist = [1, 2]\n", "arrays", 2), - ("version = 1\ntbl = { a = 1 }\n", "inline tables", 2), - ("version = 1\n[[wallet]]\n", "arrays of tables", 2), - ("version = 1\nvalidator.image = \"z:1\"\n", "dotted keys", 2), - ("version = 1\n\"quoted\" = 1\n", "quoted keys", 2), - ("version = 1\nratio = 1.5\n", "floats", 2), - ("version = 1\nflag = true\n", "booleans", 2), - ("version = 1\nwhen = 1979-05-27\n", "date-times", 2), - ( - "version = 1\nmask = 0xff\n", - "hex, octal, and binary integers", - 2, - ), - ( - "version = 1\nbig = 1_000\n", - "underscore-separated integers", - 2, - ), - ("version = 1\n[validator.env]\n", "nested tables", 2), - ]; - for (text, construct, line) in rejections { - let error = super::from_str::(text).unwrap_err().to_string(); - assert!(error.contains(construct), "{text:?} -> {error}"); - assert!( - error.contains(&format!("line {line}")), - "{text:?} -> {error}" - ); - } - } - - #[test] - fn rejects_duplicates_and_malformed_lines() { - let errors = [ - ("version = 1\nversion = 2\n", "more than once"), - ("version = 1\n[wallet]\n[wallet]\n", "more than once"), - ("version = 1\nrun = \"unterminated\n", "unterminated"), - ("version = 1\nrun = \"bad \\q escape\"\n", "unknown escape"), - ("version = 1\nrun = \"a\" junk\n", "trailing content"), - ("version = 1\nnovalue =\n", "expected a value"), - ("version = 1\nkeyonly\n", "expected `=`"), - ]; - for (text, needle) in errors { - let error = super::from_str::(text).unwrap_err().to_string(); - assert!(error.contains(needle), "{text:?} -> {error}"); - } - } - - /// Schema-level failures surface serde's own prose (no line - /// numbers — the document parsed; the shape was wrong). - #[test] - fn schema_errors_pass_through_serde() { - let unknown = super::from_str::("version = 1\nbogus = 2\n") - .unwrap_err() - .to_string(); - assert!(unknown.contains("unknown field `bogus`"), "{unknown}"); - let wrong_type = super::from_str::("version = \"1\"\n") - .unwrap_err() - .to_string(); - assert!(wrong_type.contains("invalid type"), "{wrong_type}"); - } - - /// Values the dialect cannot express fail to render rather than - /// producing unparseable output. - #[test] - fn rendering_rejects_what_the_dialect_cannot_express() { - #[derive(Serialize)] - struct Flag { - flag: bool, - } - let error = super::to_string(&Flag { flag: true }) - .unwrap_err() - .to_string(); - assert!(error.contains("booleans"), "{error}"); - } -}