Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ jobs:
--ignore RUSTSEC-2026-0094
--ignore RUSTSEC-2026-0095
--ignore RUSTSEC-2026-0096
--ignore RUSTSEC-2026-0103

deny:
name: Cargo Deny (licenses, bans, sources, advisories)
Expand Down
6 changes: 6 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ ignore = [
"RUSTSEC-2026-0094",
"RUSTSEC-2026-0095",
"RUSTSEC-2026-0096",
# thin-vec 0.2.14 Double-Free / UAF in IntoIter::drop / ThinVec::clear.
# Pulled in transitively by salsa 0.26.0. No rivet call site directly
# constructs or iterates thin_vec::ThinVec. Upstream: wait for salsa to
# bump its thin-vec dependency, or upstream fix in thin-vec >= 0.2.15.
# TODO: remove when salsa >= 0.27 lands or thin-vec fix is released.
"RUSTSEC-2026-0103",
]

[licenses]
Expand Down
80 changes: 68 additions & 12 deletions rivet-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3355,27 +3355,80 @@ fn run_salsa_validation(cli: &Cli, config: &ProjectConfig) -> Result<Vec<validat
let schema_contents =
rivet_core::embedded::load_schema_contents(&config.project.schemas, &schemas_dir);

// ── Collect source file content ─────────────────────────────────────
// Merge schema files up-front so the non-YAML adapters that need a
// schema (e.g. stpa-yaml for schema-driven extraction) can be invoked
// identically to the direct path.
let merged_schema = rivet_core::load_schemas(&config.project.schemas, &schemas_dir)
.context("loading schemas for salsa validation")?;

// ── Collect source file content and adapter-imported artifacts ──────
//
// YAML-based formats are fed to salsa as `SourceFile` inputs so every
// file becomes an incrementally-tracked parse unit. Non-YAML formats
// (aadl, reqif, needs-json, wasm) can't be represented that way —
// their adapters operate on directories, binary blobs, or run external
// tools. We invoke those adapters once here and inject the resulting
// artifacts into the salsa store via `ExtraArtifactSet` so that
// cross-format links (e.g. a YAML artifact `modeled-by -> AADL-*`)
// resolve against the full set of artifacts — matching the direct
// (`--direct`) path and eliminating the class of phantom
// "link target does not exist" diagnostics that the salsa path used
// to report for AADL / ReqIF / needs-json targets.
let mut source_contents: Vec<(String, String)> = Vec::new();
let mut extra_artifacts: Vec<rivet_core::model::Artifact> = Vec::new();
for source in &config.sources {
let source_path = cli.project.join(&source.path);
// All YAML-based formats are handled by parse_artifacts_v2 via schema-driven extraction.
match source.format.as_str() {
"generic" | "generic-yaml" | "stpa-yaml" => {
rivet_core::collect_yaml_files(&source_path, &mut source_contents)
.with_context(|| format!("reading source '{}'", source.path))?;
}
_ => {
// Non-YAML formats (reqif, aadl, needs-json) still need their own adapters.
log::debug!(
"salsa: skipping non-YAML source '{}' (format: {})",
source.path,
source.format,
);
// Non-YAML formats: run the adapter now, inject the
// resulting artifacts into the salsa store so links to
// them resolve.
match rivet_core::load_artifacts(source, &cli.project, &merged_schema) {
Ok(artifacts) => extra_artifacts.extend(artifacts),
Err(e) => {
return Err(anyhow::anyhow!(
"loading adapter source '{}' (format: {}): {}",
source.path,
source.format,
e
));
}
}
}
}
}

// Externals: the direct path (ProjectContext::load) injects external
// project artifacts with their prefix into the store. The salsa path
// must do the same or cross-repo link targets become phantom broken
// links. This mirrors the loop in ProjectContext::load.
if let Some(ref externals) = config.externals {
if !externals.is_empty() {
match rivet_core::externals::load_all_externals(externals, &cli.project) {
Ok(resolved) => {
for ext in resolved {
let ext_ids: std::collections::HashSet<String> =
ext.artifacts.iter().map(|a| a.id.clone()).collect();
for mut artifact in ext.artifacts {
artifact.id = format!("{}:{}", ext.prefix, artifact.id);
for link in &mut artifact.links {
if ext_ids.contains(&link.target) {
link.target = format!("{}:{}", ext.prefix, link.target);
}
}
extra_artifacts.push(artifact);
}
}
}
Err(e) => {
log::warn!("could not load externals for salsa validation: {e}");
}
}
}
rivet_core::collect_yaml_files(&source_path, &mut source_contents)
.with_context(|| format!("reading source '{}'", source.path))?;
}

// ── Build salsa database and run validation ─────────────────────────
Expand All @@ -3393,14 +3446,17 @@ fn run_salsa_validation(cli: &Cli, config: &ProjectConfig) -> Result<Vec<validat
let t_start = Instant::now();
let schema_set = db.load_schemas(&schema_refs);
let source_set = db.load_sources(&source_refs);
let diagnostics = db.diagnostics(source_set, schema_set);
let extra_count = extra_artifacts.len();
let extra_set = db.load_extras(extra_artifacts);
let diagnostics = db.diagnostics_with_extras(source_set, schema_set, extra_set);
let t_elapsed = t_start.elapsed();

if cli.verbose > 0 {
eprintln!(
"[salsa] validation: {:.1}ms ({} source files, {} schemas, {} diagnostics)",
"[salsa] validation: {:.1}ms ({} source files, {} adapter artifacts, {} schemas, {} diagnostics)",
t_elapsed.as_secs_f64() * 1000.0,
source_contents.len(),
extra_count,
schema_contents.len(),
diagnostics.len(),
);
Expand Down
29 changes: 24 additions & 5 deletions rivet-cli/tests/serve_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,38 @@ fn start_server() -> (Child, u16) {
.spawn()
.expect("failed to start rivet serve");

// Wait for server to be ready (up to 30s — 20 integration tests each
// spawn a server, so system resources can be tight under CI/coverage).
// Wait for server to be ready. TCP accept alone is insufficient — the
// socket binds before the artifact store finishes loading, so `fetch()`
// can race and hit the server mid-load, getting a closed connection or
// empty response (previous failure mode: status=0 on
// api_artifacts_unfiltered / api_artifacts_search under Proptest load).
//
// Fix: wait for /api/v1/health to return 200 OK. That handler only
// becomes reachable after routing is fully initialized and the store
// is populated.
let addr = format!("127.0.0.1:{port}");
for _ in 0..300 {
if std::net::TcpStream::connect(&addr).is_ok() {
return (child, port);
if let Ok(mut stream) = std::net::TcpStream::connect(&addr) {
use std::io::{Read, Write};
let req = format!(
"GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
);
if stream.write_all(req.as_bytes()).is_ok() {
let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
let mut buf = [0u8; 32];
if let Ok(n) = stream.read(&mut buf) {
if n >= 12 && &buf[..12] == b"HTTP/1.1 200" {
return (child, port);
}
}
}
}
std::thread::sleep(Duration::from_millis(100));
}
// Kill the child before panicking to avoid zombie processes.
let _ = child.kill();
let _ = child.wait();
panic!("server did not start within 30 seconds on port {port}");
panic!("server did not become healthy within 30 seconds on port {port}");
}

/// Fetch a page via HTTP. If `htmx` is true, sends the HX-Request header
Expand Down
Loading
Loading