Skip to main content

qp_wormhole_circuit_builder/
lib.rs

1//! Wormhole circuit artifact generation (leaf / private-batch / public-batch).
2//!
3//! # Trust boundary
4//!
5//! This crate is intended to run on a **trusted CI build host**. The checkout,
6//! toolchain, and output parent during generation are not modeled as
7//! adversarial. Local filesystem races against the publisher (symlink TOCTOU,
8//! planted FIFOs, concurrent staging replacement, etc.) are **out of scope**;
9//! see `wormhole/THREAT_MODEL.md`. Remaining in-scope obligations when these
10//! artifacts are later loaded: no prover.bin, canonical verifier pinning,
11//! and dummy-template validation.
12
13use anyhow::{anyhow, bail, Context, Result};
14use std::fs;
15use std::fs::create_dir_all;
16use std::path::{Path, PathBuf};
17use wormhole_aggregator::common::utils::commit_artifact_set;
18use wormhole_aggregator::public_batch::circuit::generate_public_batch_circuit_binaries;
19
20use plonky2::util::serialization::DefaultGateSerializer;
21use wormhole_aggregator::private_batch::circuit::build::generate_private_batch_circuit_binaries;
22use wormhole_circuit::circuit::circuit_logic::WormholeCircuit;
23use zk_circuits_common::circuit::wormhole_leaf_circuit_config;
24
25// Re-export CircuitBinsConfig from aggregator so users of circuit-builder can access it
26pub use wormhole_aggregator::CircuitBinsConfig;
27
28/// Generate only the leaf wormhole circuit binaries.
29///
30/// Low-level helper for partial regenerates. Prefer
31/// [`generate_all_circuit_binaries`] for production builds (whole-directory
32/// staging; see `wormhole/THREAT_MODEL.md`).
33///
34/// Note: no `prover.bin` is emitted. `WormholeProver` always builds the leaf
35/// circuit from source so a poisoned prover artifact cannot exfiltrate
36/// witness data through the proof's public-input list.
37pub fn generate_circuit_binaries<P: AsRef<Path>>(output_dir: P) -> Result<()> {
38    println!(
39        "Building wormhole leaf circuit (non-ZK by design; ZK lives at the private-batch layer)..."
40    );
41    let config = wormhole_leaf_circuit_config();
42    let circuit = WormholeCircuit::new(config)?;
43    let targets = circuit.targets();
44    let circuit_data = circuit.build_circuit();
45    println!("Circuit built.");
46
47    let gate_serializer = DefaultGateSerializer;
48
49    let output_path = output_dir.as_ref();
50    create_dir_all(output_path)?;
51
52    // Generate dummy proof BEFORE consuming circuit_data (prove() borrows, prover_data() moves)
53    println!("Generating dummy proof for aggregation padding...");
54    let dummy_proof_bytes = wormhole_aggregator::generate_dummy_proof(&circuit_data, &targets)
55        .map_err(|e| anyhow!("failed to generate dummy proof: {}", e))?;
56
57    println!("Serializing circuit data...");
58
59    let verifier_data = circuit_data.verifier_data();
60    let common_data = &verifier_data.common;
61
62    let common_bytes = common_data
63        .to_bytes(&gate_serializer)
64        .map_err(|e| anyhow!("failed to serialize common data: {}", e))?;
65    let verifier_only_bytes = verifier_data
66        .verifier_only
67        .to_bytes()
68        .map_err(|e| anyhow!("failed to serialize verifier data: {}", e))?;
69
70    println!(
71        "Publishing {}/dummy_proof.bin ({} bytes)",
72        output_path.display(),
73        dummy_proof_bytes.len()
74    );
75    commit_artifact_set(
76        output_path,
77        &[
78            ("dummy_proof.bin", dummy_proof_bytes),
79            ("common.bin", common_bytes),
80            ("verifier.bin", verifier_only_bytes),
81        ],
82        &[],
83    )?;
84    println!("Common data saved to {}/common.bin", output_path.display());
85    println!(
86        "Verifier data saved to {}/verifier.bin",
87        output_path.display()
88    );
89
90    Ok(())
91}
92
93/// Generate all circuit binaries (both regular and aggregated)
94///
95/// The full artifact set is staged in a temporary sibling directory and only
96/// swapped into `output_dir` once every stage (leaf, private-batch, optional
97/// public-batch, `config.json`) has succeeded. A failed serialization, killed
98/// process, or interrupted rerun therefore never leaves `output_dir` holding a
99/// mix of new and stale files: it either keeps its previous contents or
100/// receives the complete new set. Any pre-existing contents of `output_dir`
101/// are replaced wholesale on success.
102///
103/// # Arguments
104/// * `output_dir` - Directory to write the binaries to
105/// * `include_prover` - Whether to generate the dummy private-batch proof used for
106///   public-batch padding (requires a private-batch proving run). No circuit emits a
107///   prover binary: provers always rebuild their circuits from source (see
108///   [`generate_circuit_binaries`])
109/// * `num_leaf_proofs` - Number of leaf proofs aggregated into a single proof (must be > 0)
110/// * `num_private_batch_proofs` - Optional param for number of inner proofs (for public-batch circuit). Set to none if you only want private-batch aggregation.
111///
112/// # Errors
113/// Returns an error if proof counts are invalid (zero or exceed maximum bounds).
114/// Validation happens before any files are written to avoid partial artifact generation.
115pub fn generate_all_circuit_binaries<P: AsRef<Path>>(
116    output_dir: P,
117    include_prover: bool,
118    num_leaf_proofs: usize,
119    num_private_batch_proofs: Option<usize>,
120) -> Result<()> {
121    // Validate proof counts upfront before any writes to avoid partial artifact generation
122    let config = CircuitBinsConfig::new(num_leaf_proofs, num_private_batch_proofs)?;
123
124    let output_path = output_dir.as_ref();
125    let staging_path = create_staging_dir(output_path)?;
126
127    let generated = (|| -> Result<()> {
128        // Generate regular circuit binaries
129        generate_circuit_binaries(&staging_path)?;
130
131        // Generate aggregated circuit binaries
132        generate_private_batch_circuit_binaries(
133            &staging_path,
134            config.num_leaf_proofs,
135            include_prover,
136        )?;
137
138        // If num_private_batch_proofs is specified, generate public-batch aggregation circuit binaries
139        if let Some(num_private_batch_proofs) = config.num_private_batch_proofs {
140            generate_public_batch_circuit_binaries(
141                &staging_path,
142                num_private_batch_proofs,
143                config.num_leaf_proofs,
144            )?;
145        }
146
147        // Save config file alongside binaries. Written last: its presence marks
148        // the staged set as complete.
149        config.save(&staging_path)
150    })();
151
152    if let Err(e) = generated {
153        // A partial stage is worthless; never leave a stray staging directory
154        // behind. (The commit phase below manages its own cleanup: once the
155        // previous output set has been moved aside, the staged artifacts may
156        // be the only surviving copy and must NOT be blindly deleted.)
157        let _ = fs::remove_dir_all(&staging_path);
158        return Err(e);
159    }
160
161    commit_staging_dir(&staging_path, output_path)
162}
163
164/// Create a private staging directory next to `output_dir` (same filesystem
165/// so the final `rename` is atomic), under an unpredictable name to avoid
166/// colliding with a concurrent builder or leftover from a crashed run.
167fn create_staging_dir(output_dir: &Path) -> Result<PathBuf> {
168    let Some(name) = output_dir.file_name().and_then(|n| n.to_str()) else {
169        bail!(
170            "output dir {} has no usable directory name; pass an explicit directory path",
171            output_dir.display()
172        );
173    };
174    if let Some(parent) = output_dir.parent() {
175        if !parent.as_os_str().is_empty() {
176            create_dir_all(parent).with_context(|| {
177                format!(
178                    "failed to create output parent directory {}",
179                    parent.display()
180                )
181            })?;
182        }
183    }
184    for _ in 0..8 {
185        let candidate = output_dir.with_file_name(format!(
186            ".{}.staging-{}-{:016x}",
187            name,
188            std::process::id(),
189            rand::random::<u64>()
190        ));
191        match fs::create_dir(&candidate) {
192            Ok(()) => return Ok(candidate),
193            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
194            Err(e) => {
195                return Err(e).with_context(|| {
196                    format!("failed to create staging directory {}", candidate.display())
197                })
198            }
199        }
200    }
201    bail!(
202        "failed to create a fresh staging directory next to {}; \
203         remove stale .{}.staging-* entries and retry",
204        output_dir.display(),
205        name
206    );
207}
208
209/// Replace `output_dir` with the fully staged `staging_dir` via directory
210/// renames. A pre-existing output dir is moved aside, then removed after the
211/// swap. Whole-directory staging is operator hygiene against mixed leaf /
212/// private-batch / public-batch generations — not a local-FS adversary
213/// defense (see `wormhole/THREAT_MODEL.md`).
214///
215/// After a successful swap-in, failing to delete the moved-aside previous
216/// copy is reported as a warning, not as command failure.
217fn commit_staging_dir(staging_dir: &Path, output_dir: &Path) -> Result<()> {
218    commit_staging_dir_impl(staging_dir, output_dir, |src, dst| fs::rename(src, dst))
219}
220
221/// [`commit_staging_dir`] with an injectable rename for rollback tests.
222fn commit_staging_dir_impl(
223    staging_dir: &Path,
224    output_dir: &Path,
225    rename: impl Fn(&Path, &Path) -> std::io::Result<()>,
226) -> Result<()> {
227    if !staging_dir.is_dir() {
228        bail!(
229            "staged artifact path {} is not a directory; refusing to publish",
230            staging_dir.display()
231        );
232    }
233
234    let mut old_name = staging_dir.file_name().unwrap_or_default().to_os_string();
235    old_name.push(".old");
236    let old_path = staging_dir.with_file_name(old_name);
237    let previous_exists = output_dir.exists();
238    if previous_exists {
239        if !output_dir.is_dir() {
240            let _ = fs::remove_dir_all(staging_dir);
241            bail!(
242                "output path {} exists but is not a directory; remove or rename it and retry",
243                output_dir.display()
244            );
245        }
246        if let Err(e) = rename(output_dir, &old_path) {
247            let _ = fs::remove_dir_all(staging_dir);
248            return Err(e).with_context(|| {
249                format!(
250                    "Failed to move previous artifact dir {} aside to {}",
251                    output_dir.display(),
252                    old_path.display()
253                )
254            });
255        }
256    }
257
258    if let Err(e) = rename(staging_dir, output_dir) {
259        if previous_exists {
260            match rename(&old_path, output_dir) {
261                Ok(()) => {
262                    let _ = fs::remove_dir_all(staging_dir);
263                    return Err(e).with_context(|| {
264                        format!(
265                            "Failed to move staged artifacts {} into place at {} \
266                             (previous artifacts were restored)",
267                            staging_dir.display(),
268                            output_dir.display()
269                        )
270                    });
271                }
272                Err(rollback_err) => {
273                    return Err(e).with_context(|| {
274                        format!(
275                            "Failed to move staged artifacts {} into place at {}, and \
276                             restoring the previous artifacts also failed ({}); previous \
277                             artifacts remain at {}, new artifacts remain at {}",
278                            staging_dir.display(),
279                            output_dir.display(),
280                            rollback_err,
281                            old_path.display(),
282                            staging_dir.display()
283                        )
284                    });
285                }
286            }
287        }
288        return Err(e).with_context(|| {
289            format!(
290                "Failed to move staged artifacts {} into place at {}; \
291                 the built artifacts remain at {}",
292                staging_dir.display(),
293                output_dir.display(),
294                staging_dir.display()
295            )
296        });
297    }
298
299    if previous_exists {
300        if let Err(e) = fs::remove_dir_all(&old_path) {
301            eprintln!(
302                "warning: published artifacts to {}, but failed to remove the previous \
303                 artifact copy at {}: {e}; remove it manually if it is no longer needed",
304                output_dir.display(),
305                old_path.display()
306            );
307        }
308    }
309    Ok(())
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::fs::write;
316
317    fn unique_tmp_dir(tag: &str) -> PathBuf {
318        std::env::temp_dir().join(format!(
319            "qp-circuit-builder-test-{}-{}",
320            tag,
321            std::process::id()
322        ))
323    }
324
325    /// `--output` must be a directory; a file at that path is rejected before
326    /// the swap (operator hygiene, not local-FS adversary defense).
327    #[test]
328    fn commit_rejects_non_directory_previous_output() {
329        let root = unique_tmp_dir("commit-not-dir");
330        let _ = fs::remove_dir_all(&root);
331        create_dir_all(&root).unwrap();
332        let output = root.join("bins");
333        let staging = create_staging_dir(&output).unwrap();
334
335        write(&output, b"i am a file").unwrap();
336        write(staging.join("fresh.bin"), b"new artifact").unwrap();
337
338        let err = commit_staging_dir(&staging, &output).unwrap_err();
339        assert!(
340            format!("{err:#}").contains("not a directory"),
341            "got: {err:#}"
342        );
343        assert_eq!(
344            fs::read(&output).unwrap(),
345            b"i am a file",
346            "previous non-directory must be left untouched"
347        );
348        assert!(
349            !staging.exists(),
350            "rejected commit discards the (uncommitted) staged copy"
351        );
352
353        fs::remove_dir_all(&root).unwrap();
354    }
355
356    /// After the staged directory is renamed into place the publish has
357    /// succeeded. An undeletable moved-aside previous copy must not flip the
358    /// result to Err — CI/operators keying off the exit code would otherwise
359    /// treat a live artifact update as a failed run (audit finding).
360    #[cfg(unix)]
361    #[test]
362    fn successful_publish_is_not_failed_by_old_copy_cleanup() {
363        use std::os::unix::fs::PermissionsExt;
364
365        let root = unique_tmp_dir("commit-cleanup-best-effort");
366        let _ = fs::remove_dir_all(&root);
367        create_dir_all(&root).unwrap();
368        let output = root.join("bins");
369        let staging = create_staging_dir(&output).unwrap();
370
371        create_dir_all(&output).unwrap();
372        write(output.join("stale.bin"), b"old artifact").unwrap();
373        // Nested dir without write permission: remove_dir_all cannot unlink
374        // its children once this tree is the moved-aside `.old` copy.
375        let locked = output.join("locked");
376        create_dir_all(&locked).unwrap();
377        write(locked.join("x.bin"), b"x").unwrap();
378        let mut perms = fs::metadata(&locked).unwrap().permissions();
379        perms.set_mode(0o555);
380        fs::set_permissions(&locked, perms).unwrap();
381
382        write(staging.join("fresh.bin"), b"new artifact").unwrap();
383
384        let result = commit_staging_dir(&staging, &output);
385        assert!(
386            result.is_ok(),
387            "publish must report success even when old-copy cleanup fails: {result:?}"
388        );
389        assert!(
390            output.join("fresh.bin").exists(),
391            "new artifacts must be live at output_dir"
392        );
393        assert!(
394            !output.join("stale.bin").exists(),
395            "stale contents must not remain in the published dir"
396        );
397
398        // Make any leftover `.old` tree deletable so the test cleans up.
399        for entry in fs::read_dir(&root).unwrap() {
400            let path = entry.unwrap().path();
401            let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
402            if !name.ends_with(".old") {
403                continue;
404            }
405            let locked_old = path.join("locked");
406            if locked_old.exists() {
407                let mut p = fs::metadata(&locked_old).unwrap().permissions();
408                p.set_mode(0o755);
409                fs::set_permissions(&locked_old, p).unwrap();
410            }
411        }
412
413        fs::remove_dir_all(&root).unwrap();
414    }
415
416    /// The swap must replace stale contents wholesale, not merge into them.
417    #[test]
418    fn commit_replaces_existing_output_dir_wholesale() {
419        let root = unique_tmp_dir("commit-replace");
420        let _ = fs::remove_dir_all(&root);
421        create_dir_all(&root).unwrap();
422        let output = root.join("bins");
423        let staging = create_staging_dir(&output).unwrap();
424
425        create_dir_all(&output).unwrap();
426        write(output.join("stale.bin"), b"old artifact").unwrap();
427        write(staging.join("fresh.bin"), b"new artifact").unwrap();
428
429        commit_staging_dir(&staging, &output).unwrap();
430
431        assert!(output.join("fresh.bin").exists());
432        assert!(
433            !output.join("stale.bin").exists(),
434            "stale artifacts must not survive the swap"
435        );
436        assert!(!staging.exists(), "staging dir must be consumed");
437
438        fs::remove_dir_all(&root).unwrap();
439    }
440
441    #[test]
442    fn commit_works_when_output_dir_does_not_exist() {
443        let root = unique_tmp_dir("commit-fresh");
444        let _ = fs::remove_dir_all(&root);
445        create_dir_all(&root).unwrap();
446        let output = root.join("bins");
447        let staging = create_staging_dir(&output).unwrap();
448
449        write(staging.join("fresh.bin"), b"new artifact").unwrap();
450
451        commit_staging_dir(&staging, &output).unwrap();
452        assert!(output.join("fresh.bin").exists());
453        assert!(!staging.exists());
454
455        fs::remove_dir_all(&root).unwrap();
456    }
457
458    fn io_fail() -> std::io::Error {
459        std::io::Error::other("injected rename failure")
460    }
461
462    /// Bugbot: a failed swap-in must never delete the staged artifacts while
463    /// output_dir is empty. With a previous set present, roll it back and only
464    /// then discard the (redundant) staged copy.
465    #[test]
466    fn failed_swap_in_restores_previous_artifacts() {
467        let root = unique_tmp_dir("swap-rollback");
468        let _ = fs::remove_dir_all(&root);
469        let output = root.join("bins");
470        let staging = create_staging_dir(&output).unwrap();
471        create_dir_all(&output).unwrap();
472        write(output.join("previous.bin"), b"previous").unwrap();
473        write(staging.join("fresh.bin"), b"fresh").unwrap();
474
475        // Fail only the staging -> output rename; move-aside and rollback work.
476        let staging_src = staging.clone();
477        let err = commit_staging_dir_impl(&staging, &output, |src, dst| {
478            if src == staging_src {
479                Err(io_fail())
480            } else {
481                fs::rename(src, dst)
482            }
483        })
484        .unwrap_err();
485
486        assert!(format!("{err:#}").contains("previous artifacts were restored"));
487        assert!(
488            output.join("previous.bin").exists(),
489            "previous artifact set must be rolled back into place"
490        );
491        assert!(!staging.exists(), "redundant staged copy is discarded");
492
493        fs::remove_dir_all(&root).unwrap();
494    }
495
496    /// If the rollback fails too, BOTH copies must survive on disk and the
497    /// error must say where they are.
498    #[test]
499    fn failed_swap_in_with_failed_rollback_preserves_both_copies() {
500        let root = unique_tmp_dir("swap-rollback-fail");
501        let _ = fs::remove_dir_all(&root);
502        let output = root.join("bins");
503        let staging = create_staging_dir(&output).unwrap();
504        create_dir_all(&output).unwrap();
505        write(output.join("previous.bin"), b"previous").unwrap();
506        write(staging.join("fresh.bin"), b"fresh").unwrap();
507
508        // Fail every rename INTO output_dir: the swap-in and the rollback.
509        let output_dst = output.clone();
510        let err = commit_staging_dir_impl(&staging, &output, |src, dst| {
511            if dst == output_dst {
512                Err(io_fail())
513            } else {
514                fs::rename(src, dst)
515            }
516        })
517        .unwrap_err();
518
519        let msg = format!("{err:#}");
520        assert!(msg.contains("also failed"), "got: {msg}");
521        assert!(
522            staging.join("fresh.bin").exists(),
523            "new artifacts must survive"
524        );
525        let old_path = staging.with_file_name({
526            let mut n = staging.file_name().unwrap().to_os_string();
527            n.push(".old");
528            n
529        });
530        assert!(
531            old_path.join("previous.bin").exists(),
532            "previous artifacts must survive (moved aside)"
533        );
534
535        fs::remove_dir_all(&root).unwrap();
536    }
537
538    /// With no previous output set, a failed swap-in must leave the staged
539    /// directory (the only copy) on disk.
540    #[test]
541    fn failed_swap_in_without_previous_output_keeps_staging() {
542        let root = unique_tmp_dir("swap-fresh-fail");
543        let _ = fs::remove_dir_all(&root);
544        create_dir_all(&root).unwrap();
545        let output = root.join("bins");
546        let staging = create_staging_dir(&output).unwrap();
547        write(staging.join("fresh.bin"), b"fresh").unwrap();
548
549        let err = commit_staging_dir_impl(&staging, &output, |_, _| Err(io_fail())).unwrap_err();
550
551        assert!(format!("{err:#}").contains("remain at"), "got: {err:#}");
552        assert!(
553            staging.join("fresh.bin").exists(),
554            "only copy of the artifacts must not be deleted"
555        );
556
557        fs::remove_dir_all(&root).unwrap();
558    }
559
560    /// A failed move-aside happens before anything moves: the previous set
561    /// still serves from output_dir, so the staged copy is discarded.
562    #[test]
563    fn failed_move_aside_keeps_output_and_discards_staging() {
564        let root = unique_tmp_dir("swap-aside-fail");
565        let _ = fs::remove_dir_all(&root);
566        let output = root.join("bins");
567        let staging = create_staging_dir(&output).unwrap();
568        create_dir_all(&output).unwrap();
569        write(output.join("previous.bin"), b"previous").unwrap();
570        write(staging.join("fresh.bin"), b"fresh").unwrap();
571
572        let output_src = output.clone();
573        let err = commit_staging_dir_impl(&staging, &output, |src, dst| {
574            if src == output_src {
575                Err(io_fail())
576            } else {
577                fs::rename(src, dst)
578            }
579        })
580        .unwrap_err();
581
582        assert!(format!("{err:#}").contains("move previous"), "got: {err:#}");
583        assert!(output.join("previous.bin").exists());
584        assert!(!staging.exists());
585
586        fs::remove_dir_all(&root).unwrap();
587    }
588
589    /// A failed generation must not disturb pre-existing output contents.
590    #[test]
591    fn failed_generation_leaves_existing_output_untouched() {
592        let root = unique_tmp_dir("gen-fail");
593        let _ = fs::remove_dir_all(&root);
594        let output = root.join("bins");
595        create_dir_all(&output).unwrap();
596        write(output.join("existing.bin"), b"keep me").unwrap();
597
598        // Invalid count: fails validation before any staging.
599        assert!(generate_all_circuit_binaries(&output, false, 0, None).is_err());
600        assert!(output.join("existing.bin").exists());
601
602        fs::remove_dir_all(&root).unwrap();
603    }
604
605    /// If the staging directory cannot be created at all, generation must fail
606    /// before any circuit work begins and leave existing output untouched.
607    #[cfg(unix)]
608    #[test]
609    fn unwritable_parent_fails_before_building_and_leaves_output_untouched() {
610        use std::os::unix::fs::PermissionsExt;
611
612        let root = unique_tmp_dir("gen-parent-ro");
613        let _ = fs::remove_dir_all(&root);
614        let output = root.join("bins");
615        create_dir_all(&output).unwrap();
616        write(output.join("existing.bin"), b"keep me").unwrap();
617
618        fs::set_permissions(&root, fs::Permissions::from_mode(0o555)).unwrap();
619        // Running as root bypasses permission bits; nothing to exercise then.
620        if fs::create_dir(root.join(".probe")).is_ok() {
621            fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
622            fs::remove_dir_all(&root).unwrap();
623            return;
624        }
625
626        let result = generate_all_circuit_binaries(&output, false, 1, None);
627        fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
628
629        let err = result.unwrap_err();
630        assert!(
631            format!("{err:#}").contains("staging directory"),
632            "got: {err:#}"
633        );
634        assert!(output.join("existing.bin").exists());
635        assert!(!output.join("common.bin").exists());
636
637        fs::remove_dir_all(&root).unwrap();
638    }
639}