diff --git a/CHANGELOG.md b/CHANGELOG.md index 8603c892..30eb1490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli.md b/docs/cli.md index 0ccaa747..b20bfb6f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 | --spec-hash | --bundle ) [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 ` | Find the newest cached bundle whose manifest carries this stable finding ID | +| `--spec-hash ` | Replay an exact cache bundle by spec hash | +| `--bundle ` | 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. diff --git a/docs/dynamic.md b/docs/dynamic.md index 3e283970..01006be3 100644 --- a/docs/dynamic.md +++ b/docs/dynamic.md @@ -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// +/nyx/dynamic/repro// ``` +On Linux this is usually `~/.cache/nyx/dynamic/repro//`; on macOS +it is usually `~/Library/Caches/nyx/dynamic/repro//`. + 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/ +nyx repro --finding +nyx repro --finding --docker +``` + +You can also replay an exact bundle by spec hash, or inspect the shell script +directly: + +```bash +nyx repro --spec-hash +cd /nyx/dynamic/repro/ ./reproduce.sh ./reproduce.sh --docker ``` diff --git a/frontend/src/pages/FindingDetailPage.tsx b/frontend/src/pages/FindingDetailPage.tsx index 0b0fd686..b657a7dc 100644 --- a/frontend/src/pages/FindingDetailPage.tsx +++ b/frontend/src/pages/FindingDetailPage.tsx @@ -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 = () => { diff --git a/src/cli.rs b/src/cli.rs index 4bafc9de..2eb28d41 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, + + /// Exact spec hash / cache directory name to replay. + #[arg(long = "spec-hash", value_name = "HASH")] + spec_hash: Option, + + /// Explicit repro bundle directory. + #[arg(long, value_name = "DIR")] + bundle: Option, + + /// 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)] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8d2559f2..b48394b3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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)?; diff --git a/src/commands/repro.rs b/src/commands/repro.rs new file mode 100644 index 00000000..769ab68d --- /dev/null +++ b/src/commands/repro.rs @@ -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, + matching_bundle_count: usize, +} + +pub fn handle( + finding: Option, + spec_hash: Option, + bundle: Option, + 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 `".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 { + 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 { + 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 { + 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 { + 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}")) +} diff --git a/src/dynamic/repro.rs b/src/dynamic/repro.rs index b4c1a96e..7512c277 100644 --- a/src/dynamic/repro.rs +++ b/src/dynamic/repro.rs @@ -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, } +/// `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, + #[serde(default)] + pub spec_format_version: Option, + #[serde(default)] + pub lang: Option, + #[serde(default)] + pub entry_file: Option, + #[serde(default)] + pub entry_name: Option, + #[serde(default)] + pub sink_file: Option, + #[serde(default)] + pub sink_line: Option, + #[serde(default)] + pub toolchain_id: Option, +} + +/// 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, +} + #[derive(Debug)] pub enum ReproError { Io(std::io::Error), @@ -263,19 +301,12 @@ pub fn write( } fn repro_root(spec_hash: &str) -> Result { - // 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 { /// /// Returns `None` when the host has no resolvable cache dir. pub fn bundle_root_for(spec_hash: &str) -> Option { - 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 { + 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 { + 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, 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, 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, + pub stderr: Vec, +} + /// 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 { /// 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(), }, } } diff --git a/tests/repro_cli.rs b/tests/repro_cli.rs new file mode 100644 index 00000000..bd3d0da5 --- /dev/null +++ b/tests/repro_cli.rs @@ -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")); +}