mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-06 19:35:13 +02:00
added repro subcommand
This commit is contained in:
parent
c1fa6a87cf
commit
8a7d2b8010
9 changed files with 656 additions and 36 deletions
|
|
@ -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.
|
||||
|
|
|
|||
37
docs/cli.md
37
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 <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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
42
src/cli.rs
42
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<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)]
|
||||
|
|
|
|||
|
|
@ -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
236
src/commands/repro.rs
Normal 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}"))
|
||||
}
|
||||
|
|
@ -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
138
tests/repro_cli.rs
Normal 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"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue