added repro subcommand

This commit is contained in:
elipeter 2026-06-05 13:10:58 -05:00
parent c1fa6a87cf
commit 8a7d2b8010
9 changed files with 656 additions and 36 deletions

View file

@ -4,6 +4,12 @@ All notable changes to Nyx are documented here. The format is based on [Keep a C
## [Unreleased]
- **`nyx repro` subcommand.** Replays dynamic repro bundles by finding id,
spec hash, or explicit bundle path, with `--docker`, `--print-path`, and
`--list` helpers. The CLI now matches the browser UI's reproduced command
and uses bundle manifests to bridge stable finding ids to spec-hash cache
directories.
## [0.8.0] - 2026-06-06
The dynamic-verification release. An attack-surface map, a sandboxed dynamic verifier, a framework adapter registry that grounds both, the per-language build infrastructure that makes per-finding verification affordable at corpus scale, and the first real-corpus acceptance gates.

View file

@ -220,6 +220,43 @@ nyx scan . --max-low 50 --max-low-per-file 5
---
## `nyx repro`
Replay a dynamic repro bundle for a confirmed finding.
```
nyx repro (--finding <ID> | --spec-hash <HASH> | --bundle <DIR>) [OPTIONS]
```
Nyx writes repro bundles under the platform cache directory and keys them by
`spec_hash`. The browser UI and scan output show `finding_id`, so
`--finding` scans cached bundle manifests and replays the newest match.
| Flag | Description |
|------|-------------|
| `--finding <ID>` | Find the newest cached bundle whose manifest carries this stable finding ID |
| `--spec-hash <HASH>` | Replay an exact cache bundle by spec hash |
| `--bundle <DIR>` | Replay an explicit bundle directory |
| `--docker` | Run the bundle's Docker replay path (`./reproduce.sh --docker`) |
| `--print-path` | Print the resolved bundle path and exit without replaying |
| `--list` | With `--finding`, list all matching cached bundles newest first |
Examples:
```bash
nyx repro --finding b9caa35df2213040
nyx repro --finding b9caa35df2213040 --docker
nyx repro --finding b9caa35df2213040 --print-path
nyx repro --spec-hash 8bca7f8e0311d6c9
nyx repro --bundle /path/to/repro/8bca7f8e0311d6c9
```
Exit codes mirror `reproduce.sh`: `0` pass, `1` replay mismatch, `2` Docker
unavailable, `3` process-backend toolchain mismatch. Any other script exit is
passed through.
---
## `nyx index`
Manage the SQLite file index.

View file

@ -224,18 +224,34 @@ fails.
## Repro artifacts
Confirmed findings write a hermetic bundle:
Confirmed findings write a hermetic bundle under Nyx's platform cache
directory:
```text
~/.cache/nyx/dynamic/repro/<spec_hash>/
<cache-dir>/nyx/dynamic/repro/<spec_hash>/
```
On Linux this is usually `~/.cache/nyx/dynamic/repro/<spec_hash>/`; on macOS
it is usually `~/Library/Caches/nyx/dynamic/repro/<spec_hash>/`.
The bundle carries the harness spec, payload, expected output, trace, and a
`reproduce.sh`. When the toolchain is pinned in `tools/image-builder/images.toml`
it also writes a `docker_pull.sh`.
The easiest replay path starts from the finding id shown in scan output or the
browser UI:
```bash
cd ~/.cache/nyx/dynamic/repro/<spec_hash>
nyx repro --finding <finding_id>
nyx repro --finding <finding_id> --docker
```
You can also replay an exact bundle by spec hash, or inspect the shell script
directly:
```bash
nyx repro --spec-hash <spec_hash>
cd <cache-dir>/nyx/dynamic/repro/<spec_hash>
./reproduce.sh
./reproduce.sh --docker
```

View file

@ -710,7 +710,7 @@ export function DynamicVerdictSection({ verdict }: { verdict: VerifyResult }) {
const attempts = verdict.attempts ?? [];
// The repro bundle is keyed by spec_hash (not finding_id) inside the Nyx
// cache. Rather than showing a path that may not match, surface the CLI
// command that locates and opens the bundle regardless of the hash.
// command that resolves and replays the newest matching bundle.
const reproCmd = `nyx repro --finding ${verdict.finding_id}`;
const copyCmd = () => {

View file

@ -6,7 +6,7 @@
//! [`Commands::is_structured_output`], [`Commands::is_serve`], and
//! [`Commands::is_informational`].
use clap::{Parser, Subcommand, ValueEnum};
use clap::{ArgGroup, Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
#[derive(Parser)]
@ -61,6 +61,7 @@ impl Commands {
matches!(action, ConfigAction::Show { .. } | ConfigAction::Path)
}
Commands::Index { action } => matches!(action, IndexAction::Status { .. }),
Commands::Repro { .. } => true,
_ => false,
}
}
@ -589,6 +590,45 @@ pub enum Commands {
upload: bool,
},
/// Replay a dynamic repro bundle for a confirmed finding.
///
/// Repro bundles are keyed by spec hash in Nyx's cache, but findings shown
/// in scan output and the browser UI use a stable finding id. `--finding`
/// locates the newest matching cached bundle by reading each bundle's
/// manifest. Use `--spec-hash` when you already know the cache key, or
/// `--bundle` for an explicit bundle directory.
#[cfg_attr(not(feature = "dynamic"), command(hide = true))]
#[command(group(
ArgGroup::new("target")
.required(true)
.args(["finding", "spec_hash", "bundle"])
))]
Repro {
/// Stable finding ID shown in dynamic verdict output and the UI.
#[arg(long, value_name = "ID")]
finding: Option<String>,
/// Exact spec hash / cache directory name to replay.
#[arg(long = "spec-hash", value_name = "HASH")]
spec_hash: Option<String>,
/// Explicit repro bundle directory.
#[arg(long, value_name = "DIR")]
bundle: Option<std::path::PathBuf>,
/// Replay with the bundle's Docker backend.
#[arg(long)]
docker: bool,
/// Print the resolved bundle path and exit without replaying.
#[arg(long, conflicts_with = "list")]
print_path: bool,
/// List every cached bundle matching --finding, newest first.
#[arg(long, requires = "finding")]
list: bool,
},
/// Manage project indexes
Index {
#[command(subcommand)]

View file

@ -10,6 +10,8 @@ pub mod clean;
pub mod config;
pub mod index;
pub mod list;
#[cfg(feature = "dynamic")]
pub mod repro;
pub mod rules;
pub mod scan;
#[cfg(feature = "serve")]
@ -409,6 +411,23 @@ pub fn handle_command(
"The `dynamic` feature is not enabled. Rebuild with `cargo build --features dynamic`.".into(),
));
}
#[cfg(feature = "dynamic")]
Commands::Repro {
finding,
spec_hash,
bundle,
docker,
print_path,
list,
} => {
repro::handle(finding, spec_hash, bundle, docker, print_path, list)?;
}
#[cfg(not(feature = "dynamic"))]
Commands::Repro { .. } => {
return Err(crate::errors::NyxError::Msg(
"The `dynamic` feature is not enabled. Rebuild with `cargo build --features dynamic`.".into(),
));
}
Commands::Index { action } => {
install_from_config(config);
index::handle(action, database_dir, config)?;

236
src/commands/repro.rs Normal file
View file

@ -0,0 +1,236 @@
//! `nyx repro` subcommand.
//!
//! Replays dynamic verification bundles written for Confirmed findings. The
//! cache is keyed by spec hash, while users and the browser UI usually start
//! from a stable finding id, so this command resolves by manifest first and
//! then delegates to the bundle's `reproduce.sh`.
use crate::dynamic::repro::{self, LocatedReproBundle, ReplayResult, ReproManifest};
use crate::errors::{NyxError, NyxResult};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::exit;
#[derive(Debug)]
struct ResolvedBundle {
root: PathBuf,
manifest: Option<ReproManifest>,
matching_bundle_count: usize,
}
pub fn handle(
finding: Option<String>,
spec_hash: Option<String>,
bundle: Option<PathBuf>,
docker: bool,
print_path: bool,
list: bool,
) -> NyxResult<()> {
if list {
let finding_id = finding.as_deref().ok_or_else(|| {
NyxError::Msg("`nyx repro --list` requires `--finding <ID>`".to_owned())
})?;
return list_bundles_for_finding(finding_id);
}
let resolved = resolve_one(finding.as_deref(), spec_hash.as_deref(), bundle.as_deref())?;
if print_path {
println!("{}", resolved.root.display());
return Ok(());
}
if let Some(manifest) = &resolved.manifest
&& resolved.matching_bundle_count > 1
{
eprintln!(
"note: found {} repro bundles for finding {}; using newest spec hash {}",
resolved.matching_bundle_count, manifest.finding_id, manifest.spec_hash
);
}
replay(resolved, docker)
}
fn list_bundles_for_finding(finding_id: &str) -> NyxResult<()> {
let bundles = repro::find_bundles_by_finding_id(finding_id).map_err(repro_error)?;
if bundles.is_empty() {
return Err(NyxError::Msg(missing_finding_message(finding_id)));
}
println!(
"{} repro bundle{} for finding {} (newest first)",
bundles.len(),
if bundles.len() == 1 { "" } else { "s" },
finding_id
);
for bundle in bundles {
println!(
"{}\tspec_hash={}\ttoolchain={}",
bundle.root.display(),
bundle.manifest.spec_hash,
bundle.manifest.toolchain_id.as_deref().unwrap_or("-")
);
}
Ok(())
}
fn resolve_one(
finding: Option<&str>,
spec_hash: Option<&str>,
bundle: Option<&Path>,
) -> NyxResult<ResolvedBundle> {
match (finding, spec_hash, bundle) {
(Some(finding_id), None, None) => resolve_by_finding(finding_id),
(None, Some(spec_hash), None) => resolve_by_spec_hash(spec_hash),
(None, None, Some(path)) => resolve_by_bundle_path(path),
_ => Err(NyxError::Msg(
"choose exactly one repro target: --finding, --spec-hash, or --bundle".to_owned(),
)),
}
}
fn resolve_by_finding(finding_id: &str) -> NyxResult<ResolvedBundle> {
let mut bundles = repro::find_bundles_by_finding_id(finding_id).map_err(repro_error)?;
if bundles.is_empty() {
return Err(NyxError::Msg(missing_finding_message(finding_id)));
}
let matching_bundle_count = bundles.len();
let LocatedReproBundle { root, manifest, .. } = bundles.remove(0);
Ok(ResolvedBundle {
root,
manifest: Some(manifest),
matching_bundle_count,
})
}
fn resolve_by_spec_hash(spec_hash: &str) -> NyxResult<ResolvedBundle> {
let Some(root) = repro::bundle_root_for(spec_hash) else {
return Err(NyxError::Msg(
"cannot determine the Nyx repro cache directory on this host".to_owned(),
));
};
if !root.is_dir() {
return Err(NyxError::Msg(format!(
"no repro bundle found for spec hash `{spec_hash}` at {}",
root.display()
)));
}
let manifest = repro::read_manifest(&root).map_err(repro_error)?;
if manifest.spec_hash != spec_hash {
return Err(NyxError::Msg(format!(
"manifest at {} belongs to spec hash `{}`, not `{spec_hash}`",
root.display(),
manifest.spec_hash
)));
}
Ok(ResolvedBundle {
root,
manifest: Some(manifest),
matching_bundle_count: 1,
})
}
fn resolve_by_bundle_path(path: &Path) -> NyxResult<ResolvedBundle> {
let root = path.canonicalize().map_err(|e| {
NyxError::Msg(format!(
"cannot resolve repro bundle path {}: {e}",
path.display()
))
})?;
if !root.is_dir() {
return Err(NyxError::Msg(format!(
"repro bundle path is not a directory: {}",
root.display()
)));
}
let manifest_path = root.join("manifest.json");
let manifest = if manifest_path.is_file() {
Some(repro::read_manifest(&root).map_err(repro_error)?)
} else {
None
};
Ok(ResolvedBundle {
root,
manifest,
matching_bundle_count: 1,
})
}
fn replay(resolved: ResolvedBundle, docker: bool) -> NyxResult<()> {
let mut stdout = std::io::stdout().lock();
let mut stderr = std::io::stderr().lock();
writeln!(stdout, "Repro bundle: {}", resolved.root.display())?;
if let Some(manifest) = &resolved.manifest {
writeln!(
stdout,
"Finding: {} Spec: {}",
manifest.finding_id, manifest.spec_hash
)?;
if let Some(toolchain) = &manifest.toolchain_id {
writeln!(stdout, "Toolchain: {toolchain}")?;
}
}
writeln!(
stdout,
"Backend: {}",
if docker { "docker" } else { "process" }
)?;
let extra_args: Vec<&str> = if docker { vec!["--docker"] } else { Vec::new() };
let replay = repro::replay_bundle_capture(&resolved.root, &extra_args);
stdout.write_all(&replay.stdout)?;
if !replay.stdout.is_empty() && !replay.stdout.ends_with(b"\n") {
writeln!(stdout)?;
}
stderr.write_all(&replay.stderr)?;
if !replay.stderr.is_empty() && !replay.stderr.ends_with(b"\n") {
writeln!(stderr)?;
}
match replay.result {
ReplayResult::Pass => {
writeln!(stdout, "Replay result: pass")?;
Ok(())
}
ReplayResult::Mismatch => {
writeln!(stderr, "Replay result: mismatch")?;
exit(1);
}
ReplayResult::DockerUnavailable => {
writeln!(stderr, "Replay result: docker unavailable")?;
exit(2);
}
ReplayResult::ToolchainMismatch => {
writeln!(
stderr,
"Replay result: host toolchain mismatch; retry with --docker"
)?;
exit(3);
}
ReplayResult::UnexpectedError { exit_code } => {
writeln!(stderr, "Replay result: unexpected script exit {exit_code}")?;
exit(exit_code);
}
ReplayResult::ScriptInvocationFailed { message } => Err(NyxError::Msg(message)),
}
}
fn missing_finding_message(finding_id: &str) -> String {
let cache = repro::repro_base_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(no cache directory available)".to_owned());
format!(
"no repro bundle found for finding `{finding_id}` in {cache}; \
run `nyx scan --verify` to create one, or pass --spec-hash/--bundle for an explicit bundle"
)
}
fn repro_error(err: repro::ReproError) -> NyxError {
NyxError::Msg(format!("repro bundle error: {err}"))
}

View file

@ -47,8 +47,10 @@ use crate::dynamic::spec::HarnessSpec;
use crate::evidence::VerifyResult;
use crate::utils::redact;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// Emitted by [`write()`] on success.
#[derive(Debug, Clone)]
@ -59,6 +61,42 @@ pub struct ReproArtifact {
pub symlink: Option<PathBuf>,
}
/// `manifest.json` at the root of a repro bundle.
///
/// The manifest is the stable lookup surface for tooling that starts from a
/// finding id rather than a spec hash. New fields can be appended by the writer
/// without breaking old readers; command-line replay only requires
/// `finding_id` and `spec_hash`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReproManifest {
pub spec_hash: String,
pub finding_id: String,
#[serde(default)]
pub corpus_version: Option<u64>,
#[serde(default)]
pub spec_format_version: Option<u64>,
#[serde(default)]
pub lang: Option<String>,
#[serde(default)]
pub entry_file: Option<String>,
#[serde(default)]
pub entry_name: Option<String>,
#[serde(default)]
pub sink_file: Option<String>,
#[serde(default)]
pub sink_line: Option<u32>,
#[serde(default)]
pub toolchain_id: Option<String>,
}
/// A repro bundle discovered on disk with its parsed manifest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocatedReproBundle {
pub root: PathBuf,
pub manifest: ReproManifest,
pub modified: Option<SystemTime>,
}
#[derive(Debug)]
pub enum ReproError {
Io(std::io::Error),
@ -263,19 +301,12 @@ pub fn write(
}
fn repro_root(spec_hash: &str) -> Result<PathBuf, ReproError> {
// Respect test override.
let base = if let Ok(p) = std::env::var("NYX_REPRO_BASE") {
PathBuf::from(p)
} else {
let dirs = ProjectDirs::from("", "", "nyx").ok_or_else(|| {
ReproError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"cannot determine cache dir",
))
})?;
dirs.cache_dir().join("dynamic").join("repro")
};
let base = repro_base_dir().ok_or_else(|| {
ReproError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"cannot determine cache dir",
))
})?;
let root = base.join(spec_hash);
fs::create_dir_all(&root)?;
#[cfg(unix)]
@ -294,13 +325,85 @@ fn repro_root(spec_hash: &str) -> Result<PathBuf, ReproError> {
///
/// Returns `None` when the host has no resolvable cache dir.
pub fn bundle_root_for(spec_hash: &str) -> Option<PathBuf> {
let base = if let Ok(p) = std::env::var("NYX_REPRO_BASE") {
PathBuf::from(p)
} else {
let dirs = ProjectDirs::from("", "", "nyx")?;
dirs.cache_dir().join("dynamic").join("repro")
Some(repro_base_dir()?.join(spec_hash))
}
/// Resolve the directory that contains all repro bundles without creating it.
///
/// On macOS this follows [`directories::ProjectDirs`] to
/// `~/Library/Caches/nyx/dynamic/repro`; on Linux it follows the XDG cache
/// directory. Tests and CI can override it with `NYX_REPRO_BASE`.
pub fn repro_base_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("NYX_REPRO_BASE") {
return Some(PathBuf::from(p));
}
let dirs = ProjectDirs::from("", "", "nyx")?;
Some(dirs.cache_dir().join("dynamic").join("repro"))
}
/// Read and parse a bundle manifest.
pub fn read_manifest(bundle_root: &Path) -> Result<ReproManifest, ReproError> {
let bytes = fs::read(bundle_root.join("manifest.json"))?;
Ok(serde_json::from_slice(&bytes)?)
}
/// Resolve a bundle by spec hash and parse its manifest when present.
pub fn bundle_for_spec_hash(spec_hash: &str) -> Result<Option<LocatedReproBundle>, ReproError> {
let Some(root) = bundle_root_for(spec_hash) else {
return Ok(None);
};
Some(base.join(spec_hash))
if !root.is_dir() {
return Ok(None);
}
let manifest = read_manifest(&root)?;
Ok(Some(located_bundle(root, manifest)))
}
/// Find every cached repro bundle whose manifest carries `finding_id`.
///
/// Results are sorted newest-first by directory mtime, then by spec hash for a
/// stable tie-breaker. Incomplete or malformed bundle directories are skipped
/// so one broken cache entry does not prevent replaying a valid one.
pub fn find_bundles_by_finding_id(finding_id: &str) -> Result<Vec<LocatedReproBundle>, ReproError> {
let Some(base) = repro_base_dir() else {
return Ok(Vec::new());
};
if !base.is_dir() {
return Ok(Vec::new());
}
let mut matches = Vec::new();
for entry in fs::read_dir(base)? {
let Ok(entry) = entry else {
continue;
};
let root = entry.path();
if !root.is_dir() || !root.join("manifest.json").is_file() {
continue;
}
let Ok(manifest) = read_manifest(&root) else {
continue;
};
if manifest.finding_id == finding_id {
matches.push(located_bundle(root, manifest));
}
}
matches.sort_by(|a, b| {
b.modified
.cmp(&a.modified)
.then_with(|| a.manifest.spec_hash.cmp(&b.manifest.spec_hash))
});
Ok(matches)
}
fn located_bundle(root: PathBuf, manifest: ReproManifest) -> LocatedReproBundle {
let modified = fs::metadata(&root).and_then(|m| m.modified()).ok();
LocatedReproBundle {
root,
manifest,
modified,
}
}
fn write_json(path: &Path, value: &impl serde::Serialize) -> Result<(), ReproError> {
@ -589,6 +692,14 @@ pub enum ReplayResult {
},
}
/// Captured output from a repro replay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplayOutput {
pub result: ReplayResult,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
/// Tri-state map of [`ReplayResult`] onto the eval-corpus
/// `VerifyResult::replay_stable` field shape.
///
@ -617,11 +728,20 @@ pub fn replay_stability(result: &ReplayResult) -> Option<bool> {
/// Callers who want "did this bundle replay green?" semantics get a typed
/// result instead of parsing shell output.
pub fn replay_bundle(bundle_root: &Path, extra_args: &[&str]) -> ReplayResult {
replay_bundle_capture(bundle_root, extra_args).result
}
/// Run `reproduce.sh` and retain stdout/stderr for human-facing callers.
pub fn replay_bundle_capture(bundle_root: &Path, extra_args: &[&str]) -> ReplayOutput {
use std::process::Command;
let script = bundle_root.join("reproduce.sh");
if !script.exists() {
return ReplayResult::ScriptInvocationFailed {
message: format!("reproduce.sh missing at {}", script.display()),
return ReplayOutput {
result: ReplayResult::ScriptInvocationFailed {
message: format!("reproduce.sh missing at {}", script.display()),
},
stdout: Vec::new(),
stderr: Vec::new(),
};
}
let mut cmd = Command::new("sh");
@ -631,18 +751,26 @@ pub fn replay_bundle(bundle_root: &Path, extra_args: &[&str]) -> ReplayResult {
}
cmd.current_dir(bundle_root);
match cmd.output() {
Ok(out) => match out.status.code() {
Some(0) => ReplayResult::Pass,
Some(1) => ReplayResult::Mismatch,
Some(2) => ReplayResult::DockerUnavailable,
Some(3) => ReplayResult::ToolchainMismatch,
Some(code) => ReplayResult::UnexpectedError { exit_code: code },
None => ReplayResult::ScriptInvocationFailed {
message: "reproduce.sh terminated without an exit code".to_owned(),
Ok(out) => ReplayOutput {
result: match out.status.code() {
Some(0) => ReplayResult::Pass,
Some(1) => ReplayResult::Mismatch,
Some(2) => ReplayResult::DockerUnavailable,
Some(3) => ReplayResult::ToolchainMismatch,
Some(code) => ReplayResult::UnexpectedError { exit_code: code },
None => ReplayResult::ScriptInvocationFailed {
message: "reproduce.sh terminated without an exit code".to_owned(),
},
},
stdout: out.stdout,
stderr: out.stderr,
},
Err(e) => ReplayResult::ScriptInvocationFailed {
message: format!("failed to invoke reproduce.sh: {e}"),
Err(e) => ReplayOutput {
result: ReplayResult::ScriptInvocationFailed {
message: format!("failed to invoke reproduce.sh: {e}"),
},
stdout: Vec::new(),
stderr: Vec::new(),
},
}
}

138
tests/repro_cli.rs Normal file
View file

@ -0,0 +1,138 @@
#![cfg(feature = "dynamic")]
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::json;
use std::path::{Path, PathBuf};
fn nyx_cmd(home: &Path, repro_base: &Path) -> Command {
let mut cmd = Command::cargo_bin("nyx").expect("nyx binary must exist");
cmd.env("HOME", home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("XDG_DATA_HOME", home.join(".local/share"))
.env("XDG_CACHE_HOME", home.join(".cache"))
.env("NYX_REPRO_BASE", repro_base)
.env("NO_COLOR", "1");
cmd
}
fn write_bundle(base: &Path, spec_hash: &str, finding_id: &str, script: &str) -> PathBuf {
let root = base.join(spec_hash);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("manifest.json"),
serde_json::to_vec_pretty(&json!({
"corpus_version": 17,
"entry_file": "/fixture/app.js",
"entry_name": "handler",
"finding_id": finding_id,
"lang": "javascript",
"sink_file": "/fixture/app.js",
"sink_line": 7,
"spec_format_version": 2,
"spec_hash": spec_hash,
"toolchain_id": "node-20"
}))
.unwrap(),
)
.unwrap();
std::fs::write(root.join("reproduce.sh"), script).unwrap();
root
}
#[test]
fn repro_by_finding_replays_matching_bundle() {
let home = tempfile::tempdir().unwrap();
let repro = tempfile::tempdir().unwrap();
write_bundle(
repro.path(),
"specaaaaaaaaaaaa",
"findaaaaaaaaaaaa",
"#!/bin/sh\necho replay-ok\nexit 0\n",
);
let mut cmd = nyx_cmd(home.path(), repro.path());
cmd.args(["repro", "--finding", "findaaaaaaaaaaaa"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Repro bundle:"))
.stdout(predicate::str::contains("Finding: findaaaaaaaaaaaa"))
.stdout(predicate::str::contains("replay-ok"))
.stdout(predicate::str::contains("Replay result: pass"));
}
#[test]
fn repro_print_path_resolves_finding_without_replaying() {
let home = tempfile::tempdir().unwrap();
let repro = tempfile::tempdir().unwrap();
let bundle = write_bundle(
repro.path(),
"specbbbbbbbbbbbb",
"findbbbbbbbbbbbb",
"#!/bin/sh\necho should-not-run\nexit 7\n",
);
let mut cmd = nyx_cmd(home.path(), repro.path());
cmd.args(["repro", "--finding", "findbbbbbbbbbbbb", "--print-path"]);
cmd.assert()
.success()
.stdout(predicate::eq(format!("{}\n", bundle.display())))
.stdout(predicate::str::contains("should-not-run").not());
}
#[test]
fn repro_by_spec_hash_replays_exact_cache_bundle() {
let home = tempfile::tempdir().unwrap();
let repro = tempfile::tempdir().unwrap();
write_bundle(
repro.path(),
"speccccccccccccc",
"findcccccccccccc",
"#!/bin/sh\necho spec-replay-ok\nexit 0\n",
);
let mut cmd = nyx_cmd(home.path(), repro.path());
cmd.args(["repro", "--spec-hash", "speccccccccccccc"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Spec: speccccccccccccc"))
.stdout(predicate::str::contains("spec-replay-ok"));
}
#[test]
fn repro_missing_finding_exits_with_actionable_error() {
let home = tempfile::tempdir().unwrap();
let repro = tempfile::tempdir().unwrap();
let mut cmd = nyx_cmd(home.path(), repro.path());
cmd.args(["repro", "--finding", "missingffffffff", "--print-path"]);
cmd.assert().failure().stderr(
predicate::str::contains("no repro bundle found")
.and(predicate::str::contains("missingffffffff"))
.and(predicate::str::contains("nyx scan --verify")),
);
}
#[test]
fn repro_preserves_script_exit_code_for_infra_failures() {
let home = tempfile::tempdir().unwrap();
let repro = tempfile::tempdir().unwrap();
let bundle = write_bundle(
repro.path(),
"specdddddddddddd",
"finddddddddddddd",
"#!/bin/sh\necho docker nope >&2\nexit 2\n",
);
let mut cmd = nyx_cmd(home.path(), repro.path());
cmd.arg("repro").arg("--bundle").arg(bundle).arg("--docker");
cmd.assert()
.code(2)
.stderr(predicate::str::contains("docker nope"))
.stderr(predicate::str::contains("docker unavailable"));
}