Wave 7: Phase 2 features - sync --all, external refs, cross-alias discovery, CI/CD, reliability tests (bd-1ky, bd-1bp, bd-1rk, bd-1lj, bd-gvr, bd-1x5)

- Sync --all with async concurrency, per-host throttling, failure budgets, resumable execution
- External ref bundling at fetch time with origin tracking
- Cross-alias discovery (--all-aliases) for list and search commands
- CI/CD pipeline (.gitlab-ci.yml), cargo-deny config, Dockerfile, install script
- Reliability test suite: crash consistency (8 tests), lock contention (3 tests), property-based (4 tests)
- Criterion performance benchmarks (5 benchmarks)
- Bug fix: doctor --fix now repairs missing index.json when raw.json exists
- Bug fix: shared $ref references no longer incorrectly flagged as circular (refs.rs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-12 15:29:31 -05:00
parent 398311ca4c
commit 4ac8659ebd
20 changed files with 3430 additions and 68 deletions

File diff suppressed because one or more lines are too long

156
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,156 @@
# CI/CD pipeline for swagger-cli
# Stages: test -> build -> release
stages:
- test
- build
- release
variables:
CARGO_HOME: ${CI_PROJECT_DIR}/.cargo
RUSTFLAGS: "-D warnings"
default:
image: rust:1.93
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .cargo/
- target/
# ---------------------------------------------------------------------------
# Test stage
# ---------------------------------------------------------------------------
test:unit:
stage: test
script:
- cargo test --lib
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
test:integration:
stage: test
script:
- cargo test --test '*'
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
lint:
stage: test
script:
- rustup component add rustfmt clippy
- cargo fmt --check
- cargo clippy -- -D warnings
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
security:deps:
stage: test
script:
- cargo install cargo-deny --locked
- cargo install cargo-audit --locked
- cargo deny check
- cargo audit
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
allow_failure: false
# ---------------------------------------------------------------------------
# Build stage — cross-compile for 4 platform targets
# ---------------------------------------------------------------------------
.build-template: &build-template
stage: build
script:
- rustup target add ${TARGET}
- cargo build --release --target ${TARGET}
- mkdir -p artifacts/
- cp target/${TARGET}/release/swagger-cli artifacts/swagger-cli-${TARGET}
artifacts:
paths:
- artifacts/
expire_in: 1 week
rules:
- if: $CI_COMMIT_TAG
build:aarch64-apple-darwin:
<<: *build-template
tags:
- macos
- arm64
variables:
TARGET: aarch64-apple-darwin
build:x86_64-apple-darwin:
<<: *build-template
tags:
- macos
- x86_64
variables:
TARGET: x86_64-apple-darwin
build:x86_64-unknown-linux-gnu:
<<: *build-template
variables:
TARGET: x86_64-unknown-linux-gnu
build:aarch64-unknown-linux-gnu:
<<: *build-template
before_script:
- apt-get update -qq && apt-get install -y -qq gcc-aarch64-linux-gnu
- export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
variables:
TARGET: aarch64-unknown-linux-gnu
# ---------------------------------------------------------------------------
# Release stage — tag-only
# ---------------------------------------------------------------------------
release:artifacts:
stage: release
image: alpine:latest
dependencies:
- build:aarch64-apple-darwin
- build:x86_64-apple-darwin
- build:x86_64-unknown-linux-gnu
- build:aarch64-unknown-linux-gnu
before_script:
- apk add --no-cache curl minisign coreutils
script:
- cd artifacts/
- sha256sum swagger-cli-* > SHA256SUMS
- echo "${MINISIGN_SECRET_KEY}" > /tmp/minisign.key
- minisign -S -s /tmp/minisign.key -m SHA256SUMS
- rm -f /tmp/minisign.key
# Upload all artifacts to GitLab Package Registry
- |
for file in swagger-cli-* SHA256SUMS SHA256SUMS.minisig; do
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "${file}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/swagger-cli/${CI_COMMIT_TAG}/${file}"
done
rules:
- if: $CI_COMMIT_TAG
artifacts:
paths:
- artifacts/
release:docker:
stage: release
image: docker:latest
services:
- docker:dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
- docker build -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG} -t ${CI_REGISTRY_IMAGE}:latest .
- docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}
- docker push ${CI_REGISTRY_IMAGE}:latest
rules:
- if: $CI_COMMIT_TAG

97
Cargo.lock generated
View File

@@ -125,6 +125,21 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -507,6 +522,21 @@ dependencies = [
"winapi",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -514,6 +544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -522,6 +553,17 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.31"
@@ -557,6 +599,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1296,6 +1339,31 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proptest"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"num-traits",
"rand",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1395,6 +1463,15 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core",
]
[[package]]
name = "rayon"
version = "1.11.0"
@@ -1579,6 +1656,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -1768,8 +1857,10 @@ dependencies = [
"criterion",
"directories",
"fs2",
"futures",
"mockito",
"predicates",
"proptest",
"regex",
"reqwest",
"serde",
@@ -2090,6 +2181,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-ident"
version = "1.0.23"

View File

@@ -10,6 +10,7 @@ clap = { version = "4", features = ["derive", "env"] }
colored = "3"
directories = "6"
fs2 = "0.4"
futures = "0.3"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] }
@@ -26,9 +27,14 @@ assert_cmd = "2"
criterion = "0.5"
mockito = "1"
predicates = "3"
proptest = "1"
tempfile = "3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
[[bench]]
name = "perf"
harness = false
[profile.release]
opt-level = 3
lto = true

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# Multi-stage build for swagger-cli
# Builder: compile with musl for fully static binary
# Runtime: minimal Alpine image
FROM rust:1.93-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
RUN cargo build --release --target x86_64-unknown-linux-musl \
&& strip target/x86_64-unknown-linux-musl/release/swagger-cli
# Runtime stage
FROM alpine:latest
RUN apk add --no-cache ca-certificates
COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/swagger-cli /usr/local/bin/swagger-cli
ENTRYPOINT ["swagger-cli"]

126
benches/perf.rs Normal file
View File

@@ -0,0 +1,126 @@
//! Performance benchmarks for core operations.
//!
//! Run with: `cargo bench`
//! First run establishes baseline. Subsequent runs report regressions.
use std::fs;
use std::path::PathBuf;
use criterion::{Criterion, criterion_group, criterion_main};
fn fixture_path(name: &str) -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir)
.join("tests")
.join("fixtures")
.join(name)
}
fn load_petstore_json() -> serde_json::Value {
let path = fixture_path("petstore.json");
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
serde_json::from_slice(&bytes).expect("failed to parse petstore.json")
}
fn bench_json_parse(c: &mut Criterion) {
let path = fixture_path("petstore.json");
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
c.bench_function("parse_petstore_json", |b| {
b.iter(|| {
let _: serde_json::Value = serde_json::from_slice(&bytes).expect("parse failed");
});
});
}
fn bench_build_index(c: &mut Criterion) {
let raw_json = load_petstore_json();
c.bench_function("build_index_petstore", |b| {
b.iter(|| {
// Simulate endpoint extraction (mirrors build_index core logic)
if let Some(paths) = raw_json.get("paths").and_then(|v| v.as_object()) {
let mut endpoints = Vec::new();
for (path, methods) in paths {
if let Some(methods_map) = methods.as_object() {
for (method, _op) in methods_map {
endpoints.push((path.clone(), method.clone()));
}
}
}
assert!(!endpoints.is_empty());
}
});
});
}
fn bench_hash_computation(c: &mut Criterion) {
let path = fixture_path("petstore.json");
let bytes = fs::read(&path).expect("failed to read petstore.json");
c.bench_function("sha256_petstore", |b| {
b.iter(|| {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&bytes);
let _result = format!("sha256:{:x}", hasher.finalize());
});
});
}
fn bench_json_pointer_resolution(c: &mut Criterion) {
let raw_json = load_petstore_json();
c.bench_function("json_pointer_resolution", |b| {
b.iter(|| {
let _ = raw_json
.pointer("/paths/~1pets/get/summary")
.map(|v| v.as_str());
let _ = raw_json
.pointer("/components/schemas/Pet")
.map(|v| v.is_object());
let _ = raw_json.pointer("/info/title").map(|v| v.as_str());
});
});
}
fn bench_search_scoring(c: &mut Criterion) {
let raw_json = load_petstore_json();
// Pre-extract summaries for search simulation
let mut summaries: Vec<String> = Vec::new();
if let Some(paths) = raw_json.get("paths").and_then(|v| v.as_object()) {
for (_path, methods) in paths {
if let Some(methods_map) = methods.as_object() {
for (_method, op) in methods_map {
if let Some(summary) = op.get("summary").and_then(|v| v.as_str()) {
summaries.push(summary.to_lowercase());
}
}
}
}
}
c.bench_function("search_scoring_pet", |b| {
let query = "pet";
b.iter(|| {
let mut matches = 0u32;
for summary in &summaries {
if summary.contains(query) {
matches += 1;
}
}
assert!(matches > 0);
});
});
}
criterion_group!(
benches,
bench_json_parse,
bench_build_index,
bench_hash_computation,
bench_json_pointer_resolution,
bench_search_scoring,
);
criterion_main!(benches);

53
deny.toml Normal file
View File

@@ -0,0 +1,53 @@
# cargo-deny configuration for swagger-cli
# https://embarkstudios.github.io/cargo-deny/
[graph]
targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
]
[advisories]
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/rustsec/advisory-db"]
vulnerability = "deny"
unmaintained = "warn"
unsound = "warn"
yanked = "deny"
notice = "warn"
[licenses]
unlicensed = "deny"
copyleft = "deny"
allow = [
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"Unicode-3.0",
"Unicode-DFS-2016",
"OpenSSL",
]
confidence-threshold = 0.8
[[licenses.clarify]]
name = "ring"
expression = "MIT AND ISC AND OpenSSL"
license-files = [
{ path = "LICENSE", hash = 0xbd0eed23 },
]
[bans]
multiple-versions = "warn"
wildcards = "deny"
highlight = "all"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []

186
install.sh Executable file
View File

@@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -euo pipefail
# swagger-cli installer
# Downloads the correct binary for your platform, verifies its checksum,
# and installs it to /usr/local/bin or ~/.local/bin.
REPO_URL="${SWAGGER_CLI_REPO_URL:-https://gitlab.com/api/v4/projects/YOUR_PROJECT_ID/packages/generic/swagger-cli}"
VERSION="${SWAGGER_CLI_VERSION:-latest}"
# --- Helpers ---------------------------------------------------------------
die() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
info() {
printf ' %s\n' "$1"
}
# --- Secure temp directory with cleanup trap --------------------------------
TMPDIR_INSTALL="$(mktemp -d)"
trap 'rm -rf "${TMPDIR_INSTALL}"' EXIT INT TERM
# --- OS / arch detection ----------------------------------------------------
detect_os() {
local os
os="$(uname -s)"
case "${os}" in
Darwin) echo "apple-darwin" ;;
Linux) echo "unknown-linux-gnu" ;;
*) die "Unsupported operating system: ${os}. Only macOS and Linux are supported." ;;
esac
}
detect_arch() {
local arch
arch="$(uname -m)"
case "${arch}" in
x86_64|amd64) echo "x86_64" ;;
arm64|aarch64) echo "aarch64" ;;
*) die "Unsupported architecture: ${arch}. Only x86_64 and aarch64 are supported." ;;
esac
}
# --- Checksum verification -------------------------------------------------
verify_checksum() {
local file="$1"
local checksums_file="$2"
local basename
basename="$(basename "${file}")"
local expected
expected="$(grep "${basename}" "${checksums_file}" | awk '{print $1}')"
if [ -z "${expected}" ]; then
die "No checksum found for ${basename} in SHA256SUMS"
fi
local actual
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "${file}" | awk '{print $1}')"
elif command -v shasum >/dev/null 2>&1; then
actual="$(shasum -a 256 "${file}" | awk '{print $1}')"
else
die "Neither sha256sum nor shasum found. Cannot verify checksum."
fi
if [ "${actual}" != "${expected}" ]; then
die "Checksum mismatch for ${basename}: expected ${expected}, got ${actual}"
fi
info "Checksum verified: ${basename}"
}
# --- Optional minisign verification -----------------------------------------
verify_signature() {
local checksums_file="$1"
local sig_file="${checksums_file}.minisig"
if [ ! -f "${sig_file}" ]; then
info "No signature file found; skipping signature verification."
return 0
fi
if ! command -v minisign >/dev/null 2>&1; then
info "minisign not found; skipping signature verification."
info "Install minisign to enable: https://jedisct1.github.io/minisign/"
return 0
fi
if [ -z "${SWAGGER_CLI_MINISIGN_PUBKEY:-}" ]; then
info "SWAGGER_CLI_MINISIGN_PUBKEY not set; skipping signature verification."
return 0
fi
minisign -V -P "${SWAGGER_CLI_MINISIGN_PUBKEY}" -m "${checksums_file}" \
|| die "Signature verification failed for SHA256SUMS"
info "Signature verified: SHA256SUMS"
}
# --- Install location -------------------------------------------------------
select_install_dir() {
if [ -w /usr/local/bin ]; then
echo "/usr/local/bin"
else
local user_bin="${HOME}/.local/bin"
mkdir -p "${user_bin}"
echo "${user_bin}"
fi
}
check_path() {
local dir="$1"
case ":${PATH}:" in
*":${dir}:"*) ;;
*)
printf '\nWarning: %s is not in your PATH.\n' "${dir}"
printf 'Add it with: export PATH="%s:${PATH}"\n' "${dir}"
;;
esac
}
# --- Download ---------------------------------------------------------------
download() {
local url="$1"
local output="$2"
if command -v curl >/dev/null 2>&1; then
curl --fail --silent --location --output "${output}" "${url}"
elif command -v wget >/dev/null 2>&1; then
wget --quiet --output-document="${output}" "${url}"
else
die "Neither curl nor wget found. Cannot download."
fi
}
# --- Main -------------------------------------------------------------------
main() {
local os arch target binary_name download_base
printf 'Installing swagger-cli %s\n' "${VERSION}"
os="$(detect_os)"
arch="$(detect_arch)"
target="${arch}-${os}"
binary_name="swagger-cli-${target}"
download_base="${REPO_URL}/${VERSION}"
info "Detected platform: ${target}"
# Download binary and checksums
info "Downloading ${binary_name}..."
download "${download_base}/${binary_name}" "${TMPDIR_INSTALL}/${binary_name}"
download "${download_base}/SHA256SUMS" "${TMPDIR_INSTALL}/SHA256SUMS"
# Attempt to download signature file (optional)
download "${download_base}/SHA256SUMS.minisig" "${TMPDIR_INSTALL}/SHA256SUMS.minisig" 2>/dev/null || true
# Verify
verify_checksum "${TMPDIR_INSTALL}/${binary_name}" "${TMPDIR_INSTALL}/SHA256SUMS"
verify_signature "${TMPDIR_INSTALL}/SHA256SUMS"
# Install
local install_dir
install_dir="$(select_install_dir)"
install -m 755 "${TMPDIR_INSTALL}/${binary_name}" "${install_dir}/swagger-cli"
info "Installed to ${install_dir}/swagger-cli"
check_path "${install_dir}"
printf '\nswagger-cli %s installed successfully.\n' "${VERSION}"
printf 'Run "swagger-cli --help" to get started.\n'
}
main "$@"

View File

@@ -159,8 +159,14 @@ fn check_alias(cm: &CacheManager, alias: &str, stale_threshold_days: u32) -> Ali
}
Err(e) => {
issues.push(format!("load error: {e}"));
status = HealthStatus::Unhealthy;
unfixable = true;
// If raw.json exists, the index can be rebuilt from it
if cm.alias_dir(alias).join("raw.json").exists() {
status = HealthStatus::Degraded;
fixable = true;
} else {
status = HealthStatus::Unhealthy;
unfixable = true;
}
(None, None)
}
};

View File

@@ -8,6 +8,7 @@ use tokio::io::AsyncReadExt;
use crate::core::cache::{CacheManager, compute_hash, validate_alias};
use crate::core::config::{AuthType, Config, cache_dir, config_path, resolve_credential};
use crate::core::external_refs::{ExternalRefConfig, resolve_external_refs};
use crate::core::http::AsyncHttpClient;
use crate::core::indexer::{Format, build_index, detect_format, normalize_to_json};
use crate::core::network::{NetworkPolicy, resolve_policy};
@@ -63,6 +64,22 @@ pub struct Args {
/// Allow plain HTTP (insecure)
#[arg(long)]
pub allow_insecure_http: bool,
/// Resolve external $ref entries by fetching and inlining them
#[arg(long)]
pub resolve_external_refs: bool,
/// Allowed host for external ref fetching (repeatable, required with --resolve-external-refs)
#[arg(long = "ref-allow-host")]
pub ref_allow_host: Vec<String>,
/// Maximum chain depth for transitive external refs (default: 10)
#[arg(long, default_value = "10")]
pub ref_max_depth: u32,
/// Maximum total bytes fetched for external refs (default: 10 MB)
#[arg(long, default_value = "10485760")]
pub ref_max_bytes: u64,
}
// ---------------------------------------------------------------------------
@@ -274,7 +291,36 @@ async fn fetch_inner(
};
let json_bytes = normalize_to_json(&raw_bytes, format)?;
let value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
let mut value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
// External ref resolution (optional)
if args.resolve_external_refs {
if args.ref_allow_host.is_empty() {
return Err(SwaggerCliError::Usage(
"--resolve-external-refs requires at least one --ref-allow-host".to_string(),
));
}
let ref_config = ExternalRefConfig {
allow_hosts: args.ref_allow_host.clone(),
max_depth: args.ref_max_depth,
max_bytes: args.ref_max_bytes,
};
let ref_client = AsyncHttpClient::builder()
.overall_timeout(Duration::from_millis(args.timeout_ms))
.max_bytes(args.max_bytes)
.max_retries(args.retries)
.allow_insecure_http(args.allow_insecure_http)
.allowed_private_hosts(args.allow_private_host.clone())
.network_policy(network_policy)
.build();
resolve_external_refs(&mut value, source_url.as_deref(), &ref_config, &ref_client).await?;
}
// Re-serialize the (possibly bundled) value to get the final json_bytes
let json_bytes = serde_json::to_vec(&value)?;
// Compute content hash for indexing
let content_hash = compute_hash(&raw_bytes);
@@ -438,6 +484,10 @@ mod tests {
retries: 2,
allow_private_host: vec![],
allow_insecure_http: false,
resolve_external_refs: false,
ref_allow_host: vec![],
ref_max_depth: 10,
ref_max_bytes: 10485760,
}
}
@@ -615,6 +665,10 @@ mod tests {
retries: 2,
allow_private_host: vec![],
allow_insecure_http: false,
resolve_external_refs: false,
ref_allow_host: vec![],
ref_max_depth: 10,
ref_max_bytes: 10485760,
}
}

View File

@@ -16,8 +16,12 @@ use crate::output::table::render_table_or_empty;
/// List endpoints from a cached spec
#[derive(Debug, ClapArgs)]
pub struct Args {
/// Alias of the cached spec
pub alias: String,
/// Alias of the cached spec (omit when using --all-aliases)
pub alias: Option<String>,
/// Query across every cached alias
#[arg(long)]
pub all_aliases: bool,
/// Filter by HTTP method (case-insensitive)
#[arg(long, short = 'm')]
@@ -57,6 +61,18 @@ struct ListOutput {
meta: ListMeta,
}
#[derive(Debug, Serialize)]
struct AllAliasesListOutput {
endpoints: Vec<AliasEndpointEntry>,
aliases_searched: Vec<String>,
total: usize,
filtered: usize,
applied_filters: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
warnings: Vec<String>,
duration_ms: u64,
}
#[derive(Debug, Serialize)]
struct EndpointEntry {
path: String,
@@ -67,6 +83,17 @@ struct EndpointEntry {
deprecated: bool,
}
#[derive(Debug, Serialize)]
struct AliasEndpointEntry {
alias: String,
path: String,
method: String,
summary: Option<String>,
operation_id: Option<String>,
tags: Vec<String>,
deprecated: bool,
}
#[derive(Debug, Serialize)]
struct ListMeta {
alias: String,
@@ -89,11 +116,31 @@ struct EndpointRow {
summary: String,
}
#[derive(Tabled)]
struct AliasEndpointRow {
#[tabled(rename = "ALIAS")]
alias: String,
#[tabled(rename = "METHOD")]
method: String,
#[tabled(rename = "PATH")]
path: String,
#[tabled(rename = "SUMMARY")]
summary: String,
}
// ---------------------------------------------------------------------------
// Execute
// ---------------------------------------------------------------------------
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
if args.all_aliases {
return execute_all_aliases(args, robot_mode).await;
}
let alias = args.alias.as_deref().ok_or_else(|| {
SwaggerCliError::Usage("An alias is required unless --all-aliases is specified".to_string())
})?;
let start = Instant::now();
// Compile path regex early so we fail fast on invalid patterns
@@ -108,7 +155,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
};
let cm = CacheManager::new(cache_dir());
let (index, meta) = cm.load_index(&args.alias)?;
let (index, meta) = cm.load_index(alias)?;
let total = index.endpoints.len();
@@ -211,7 +258,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
filtered: filtered_count,
applied_filters,
meta: ListMeta {
alias: args.alias.clone(),
alias: alias.to_string(),
spec_version: meta.spec_version.clone(),
cached_at: meta.fetched_at.to_rfc3339(),
duration_ms: duration.as_millis().min(u64::MAX as u128) as u64,
@@ -253,6 +300,173 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
Ok(())
}
// ---------------------------------------------------------------------------
// All-aliases execution
// ---------------------------------------------------------------------------
async fn execute_all_aliases(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
let start = Instant::now();
let path_regex = match &args.path {
Some(pattern) => {
let re = Regex::new(pattern).map_err(|e| {
SwaggerCliError::Usage(format!("Invalid path regex '{pattern}': {e}"))
})?;
Some(re)
}
None => None,
};
let cm = CacheManager::new(cache_dir());
let alias_metas = cm.list_aliases()?;
if alias_metas.is_empty() {
return Err(SwaggerCliError::Usage(
"No cached aliases found. Fetch a spec first with 'swagger-cli fetch'.".to_string(),
));
}
let method_upper = args.method.as_ref().map(|m| m.to_uppercase());
let tag_lower = args.tag.as_ref().map(|t| t.to_lowercase());
let mut all_entries: Vec<AliasEndpointEntry> = Vec::new();
let mut aliases_searched: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut total_endpoints: usize = 0;
let mut sorted_aliases: Vec<_> = alias_metas.iter().map(|m| m.alias.as_str()).collect();
sorted_aliases.sort();
for alias_name in &sorted_aliases {
match cm.load_index(alias_name) {
Ok((index, _meta)) => {
aliases_searched.push((*alias_name).to_string());
total_endpoints += index.endpoints.len();
for ep in &index.endpoints {
if let Some(ref m) = method_upper
&& ep.method.to_uppercase() != *m
{
continue;
}
if let Some(ref t) = tag_lower
&& !ep
.tags
.iter()
.any(|tag| tag.to_lowercase().contains(t.as_str()))
{
continue;
}
if let Some(ref re) = path_regex
&& !re.is_match(&ep.path)
{
continue;
}
all_entries.push(AliasEndpointEntry {
alias: (*alias_name).to_string(),
path: ep.path.clone(),
method: ep.method.clone(),
summary: ep.summary.clone(),
operation_id: ep.operation_id.clone(),
tags: ep.tags.clone(),
deprecated: ep.deprecated,
});
}
}
Err(e) => {
warnings.push(format!("Failed to load alias '{alias_name}': {e}"));
}
}
}
if aliases_searched.is_empty() {
return Err(SwaggerCliError::Cache(
"All aliases failed to load".to_string(),
));
}
let filtered_count = all_entries.len();
// Sort: alias ASC, path ASC, method_rank ASC
all_entries.sort_by(|a, b| {
a.alias
.cmp(&b.alias)
.then_with(|| a.path.cmp(&b.path))
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
});
// ---- Limit ----
if !args.all {
all_entries.truncate(args.limit);
}
let duration = start.elapsed();
if robot_mode {
let mut applied_filters = BTreeMap::new();
if let Some(ref m) = args.method {
applied_filters.insert("method".into(), m.clone());
}
if let Some(ref t) = args.tag {
applied_filters.insert("tag".into(), t.clone());
}
if let Some(ref p) = args.path {
applied_filters.insert("path".into(), p.clone());
}
let output = AllAliasesListOutput {
endpoints: all_entries,
aliases_searched,
total: total_endpoints,
filtered: filtered_count,
applied_filters,
warnings,
duration_ms: duration.as_millis().min(u64::MAX as u128) as u64,
};
robot::robot_success(output, "list", duration);
} else {
println!("All aliases ({} searched)\n", aliases_searched.len());
let rows: Vec<AliasEndpointRow> = all_entries
.iter()
.map(|ep| AliasEndpointRow {
alias: ep.alias.clone(),
method: ep.method.clone(),
path: ep.path.clone(),
summary: ep.summary.clone().unwrap_or_default(),
})
.collect();
let table = render_table_or_empty(&rows, "No endpoints match the given filters.");
println!("{table}");
if !rows.is_empty() {
println!();
if filtered_count > rows.len() {
println!(
"Showing {} of {} (filtered from {}). Use --all to show everything.",
rows.len(),
filtered_count,
total_endpoints
);
} else {
println!("Showing {} of {}", rows.len(), total_endpoints);
}
}
if !warnings.is_empty() {
println!();
for w in &warnings {
eprintln!("Warning: {w}");
}
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

View File

@@ -5,6 +5,7 @@ use serde::Serialize;
use crate::core::cache::CacheManager;
use crate::core::config::cache_dir;
use crate::core::indexer::method_rank;
use crate::core::search::{SearchEngine, SearchOptions, SearchResult, SearchResultType};
use crate::errors::SwaggerCliError;
use crate::output::robot;
@@ -12,11 +13,17 @@ use crate::output::robot;
/// Search endpoints and schemas by keyword
#[derive(Debug, ClapArgs)]
pub struct Args {
/// Alias of the cached spec
pub alias: String,
/// Alias of the cached spec, or search query when using --all-aliases
#[arg(required = true)]
pub alias_or_query: String,
/// Search query
pub query: String,
/// Search query (required unless --all-aliases is used, in which case the
/// first positional argument is treated as the query)
pub query: Option<String>,
/// Query across every cached alias
#[arg(long)]
pub all_aliases: bool,
/// Case-sensitive matching
#[arg(long)]
@@ -45,6 +52,15 @@ struct RobotOutput {
total: usize,
}
#[derive(Debug, Serialize)]
struct AllAliasesRobotOutput {
results: Vec<AliasRobotResult>,
aliases_searched: Vec<String>,
total: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
warnings: Vec<String>,
}
#[derive(Debug, Serialize)]
struct RobotResult {
#[serde(rename = "type")]
@@ -59,6 +75,21 @@ struct RobotResult {
matches: Vec<RobotMatch>,
}
#[derive(Debug, Serialize)]
struct AliasRobotResult {
alias: String,
#[serde(rename = "type")]
result_type: &'static str,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
rank: usize,
score: u32,
matches: Vec<RobotMatch>,
}
#[derive(Debug, Serialize)]
struct RobotMatch {
field: String,
@@ -123,7 +154,31 @@ fn parse_in_fields(raw: &str) -> Result<(bool, bool, bool), SwaggerCliError> {
// Execute
// ---------------------------------------------------------------------------
/// Extract (alias, query) from args. When `--all-aliases` is set, the first
/// positional is the query; otherwise first is alias and second is query.
fn resolve_alias_and_query(args: &Args) -> Result<(Option<&str>, &str), SwaggerCliError> {
if args.all_aliases {
// In all-aliases mode: alias_or_query IS the query
Ok((None, &args.alias_or_query))
} else {
// Normal mode: alias_or_query is the alias, query is required
let query = args.query.as_deref().ok_or_else(|| {
SwaggerCliError::Usage(
"A search query is required. Usage: search <alias> <query>".to_string(),
)
})?;
Ok((Some(args.alias_or_query.as_str()), query))
}
}
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
if args.all_aliases {
return execute_all_aliases(args, robot_mode).await;
}
let (alias, query) = resolve_alias_and_query(args)?;
let alias = alias.expect("alias is always Some in non-all-aliases mode");
let start = Instant::now();
let (search_paths, search_descriptions, search_schemas) = match &args.in_fields {
@@ -132,7 +187,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
};
let cm = CacheManager::new(cache_dir());
let (index, _meta) = cm.load_index(&args.alias)?;
let (index, _meta) = cm.load_index(alias)?;
let opts = SearchOptions {
search_paths,
@@ -144,7 +199,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
};
let engine = SearchEngine::new(&index);
let results = engine.search(&args.query, &opts);
let results = engine.search(query, &opts);
if robot_mode {
let output = RobotOutput {
@@ -153,13 +208,12 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
};
robot::robot_success(output, "search", start.elapsed());
} else if results.is_empty() {
println!("No results found for '{}'", args.query);
println!("No results found for '{query}'");
} else {
println!(
"Found {} result{} for '{}':\n",
"Found {} result{} for '{query}':\n",
results.len(),
if results.len() == 1 { "" } else { "s" },
args.query,
);
for r in &results {
let type_label = match r.result_type {
@@ -188,3 +242,168 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
Ok(())
}
// ---------------------------------------------------------------------------
// All-aliases execution
// ---------------------------------------------------------------------------
async fn execute_all_aliases(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
let (_alias, query) = resolve_alias_and_query(args)?;
let start = Instant::now();
let (search_paths, search_descriptions, search_schemas) = match &args.in_fields {
Some(fields) => parse_in_fields(fields)?,
None => (true, true, true),
};
let cm = CacheManager::new(cache_dir());
let alias_metas = cm.list_aliases()?;
if alias_metas.is_empty() {
return Err(SwaggerCliError::Usage(
"No cached aliases found. Fetch a spec first with 'swagger-cli fetch'.".to_string(),
));
}
let opts = SearchOptions {
search_paths,
search_descriptions,
search_schemas,
case_sensitive: args.case_sensitive,
exact: args.exact,
limit: usize::MAX, // collect all, then limit after merge
};
let mut all_results: Vec<(String, SearchResult)> = Vec::new();
let mut aliases_searched: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut sorted_aliases: Vec<_> = alias_metas.iter().map(|m| m.alias.clone()).collect();
sorted_aliases.sort();
for alias_name in &sorted_aliases {
match cm.load_index(alias_name) {
Ok((index, _meta)) => {
aliases_searched.push(alias_name.clone());
let engine = SearchEngine::new(&index);
let results = engine.search(query, &opts);
for r in results {
all_results.push((alias_name.clone(), r));
}
}
Err(e) => {
warnings.push(format!("Failed to load alias '{alias_name}': {e}"));
}
}
}
if aliases_searched.is_empty() {
return Err(SwaggerCliError::Cache(
"All aliases failed to load".to_string(),
));
}
// Sort by score DESC, then type ordinal ASC, name ASC, method_rank ASC, alias ASC
all_results.sort_by(|(alias_a, a), (alias_b, b)| {
b.score
.cmp(&a.score)
.then_with(|| a.result_type.ordinal().cmp(&b.result_type.ordinal()))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| {
let a_rank = a.method.as_deref().map(method_rank).unwrap_or(u8::MAX);
let b_rank = b.method.as_deref().map(method_rank).unwrap_or(u8::MAX);
a_rank.cmp(&b_rank)
})
.then_with(|| alias_a.cmp(alias_b))
});
// Apply limit
all_results.truncate(args.limit);
// Assign 1-based ranks
for (i, (_alias, result)) in all_results.iter_mut().enumerate() {
result.rank = i + 1;
}
let total = all_results.len();
if robot_mode {
let robot_results: Vec<AliasRobotResult> = all_results
.iter()
.map(|(alias, r)| AliasRobotResult {
alias: alias.clone(),
result_type: match r.result_type {
SearchResultType::Endpoint => "endpoint",
SearchResultType::Schema => "schema",
},
name: r.name.clone(),
method: r.method.clone(),
summary: r.summary.clone(),
rank: r.rank,
score: r.score,
matches: r
.matches
.iter()
.map(|m| RobotMatch {
field: m.field.clone(),
snippet: m.snippet.clone(),
})
.collect(),
})
.collect();
let output = AllAliasesRobotOutput {
total,
results: robot_results,
aliases_searched,
warnings,
};
robot::robot_success(output, "search", start.elapsed());
} else {
if all_results.is_empty() {
println!(
"No results found for '{query}' across {} aliases",
aliases_searched.len()
);
} else {
println!(
"Found {} result{} for '{query}' across {} aliases:\n",
total,
if total == 1 { "" } else { "s" },
aliases_searched.len(),
);
for (alias, r) in &all_results {
let type_label = match r.result_type {
SearchResultType::Endpoint => "endpoint",
SearchResultType::Schema => "schema",
};
let method_str = r
.method
.as_deref()
.map(|m| format!("{m} "))
.unwrap_or_default();
let summary_str = r
.summary
.as_deref()
.map(|s| format!(" - {s}"))
.unwrap_or_default();
println!(
" {rank}. [{alias}] [{type_label}] {method_str}{name}{summary_str} (score: {score})",
rank = r.rank,
name = r.name,
score = r.score,
);
}
}
if !warnings.is_empty() {
println!();
for w in &warnings {
eprintln!("Warning: {w}");
}
}
}
Ok(())
}

File diff suppressed because it is too large Load Diff

641
src/core/external_refs.rs Normal file
View File

@@ -0,0 +1,641 @@
use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use reqwest::Url;
use serde_json::Value;
use crate::core::http::AsyncHttpClient;
use crate::core::indexer::{detect_format, normalize_to_json};
use crate::errors::SwaggerCliError;
/// Configuration for external `$ref` resolution.
pub struct ExternalRefConfig {
/// Allowed hostnames for external ref fetching.
pub allow_hosts: Vec<String>,
/// Maximum chain depth for transitive external refs.
pub max_depth: u32,
/// Maximum total bytes fetched across all external refs.
pub max_bytes: u64,
}
/// Statistics returned after resolving external refs.
#[derive(Debug, Default)]
pub struct ResolveStats {
pub refs_resolved: usize,
pub refs_skipped: usize,
pub total_bytes_fetched: u64,
}
/// Resolve all external `$ref` entries in `value` by fetching and inlining them.
///
/// - Only resolves refs whose URL host is in `config.allow_hosts`.
/// - Internal refs (starting with `#/`) are left untouched.
/// - Circular external refs are detected and replaced with a marker.
/// - Stops when `config.max_depth` or `config.max_bytes` is exceeded.
///
/// Returns the resolution statistics.
pub async fn resolve_external_refs(
value: &mut Value,
base_url: Option<&str>,
config: &ExternalRefConfig,
client: &AsyncHttpClient,
) -> Result<ResolveStats, SwaggerCliError> {
let mut visited = HashSet::new();
let mut stats = ResolveStats::default();
resolve_recursive(value, base_url, config, client, 0, &mut visited, &mut stats).await?;
Ok(stats)
}
fn resolve_recursive<'a>(
value: &'a mut Value,
base_url: Option<&'a str>,
config: &'a ExternalRefConfig,
client: &'a AsyncHttpClient,
depth: u32,
visited: &'a mut HashSet<String>,
stats: &'a mut ResolveStats,
) -> Pin<Box<dyn Future<Output = Result<(), SwaggerCliError>> + Send + 'a>> {
Box::pin(async move {
if let Some(ref_str) = extract_external_ref(value) {
// Internal refs: leave untouched
if ref_str.starts_with("#/") {
return Ok(());
}
// Resolve the URL (may be relative)
let resolved_url = resolve_ref_url(&ref_str, base_url)?;
// Check for cycles
if visited.contains(&resolved_url) {
*value = serde_json::json!({ "$circular_external_ref": resolved_url });
stats.refs_skipped += 1;
return Ok(());
}
// Check depth limit
if depth >= config.max_depth {
stats.refs_skipped += 1;
return Ok(());
}
// Check host allowlist
let parsed = Url::parse(&resolved_url).map_err(|e| {
SwaggerCliError::InvalidSpec(format!(
"invalid external ref URL '{resolved_url}': {e}"
))
})?;
let host = parsed.host_str().ok_or_else(|| {
SwaggerCliError::InvalidSpec(format!(
"external ref URL '{resolved_url}' has no host"
))
})?;
if !config.allow_hosts.iter().any(|h| h == host) {
return Err(SwaggerCliError::PolicyBlocked(format!(
"external ref host '{host}' is not in --ref-allow-host allowlist. \
Add --ref-allow-host {host} to allow fetching from this host."
)));
}
// Check bytes limit before fetch
if stats.total_bytes_fetched >= config.max_bytes {
stats.refs_skipped += 1;
return Ok(());
}
// Fetch the external ref
let result = client.fetch_spec(&resolved_url).await?;
let fetched_bytes = result.bytes.len() as u64;
if stats.total_bytes_fetched + fetched_bytes > config.max_bytes {
return Err(SwaggerCliError::PolicyBlocked(format!(
"external ref total bytes would exceed --ref-max-bytes limit of {}. \
Resolved {} refs ({} bytes) before hitting the limit.",
config.max_bytes, stats.refs_resolved, stats.total_bytes_fetched
)));
}
stats.total_bytes_fetched += fetched_bytes;
// Parse the fetched content as JSON or YAML
let format = detect_format(
&result.bytes,
Some(&resolved_url),
result.content_type.as_deref(),
);
let json_bytes = normalize_to_json(&result.bytes, format).map_err(|_| {
SwaggerCliError::InvalidSpec(format!(
"external ref '{resolved_url}' returned invalid JSON/YAML"
))
})?;
let mut fetched_value: Value = serde_json::from_slice(&json_bytes)?;
// Handle fragment pointer within the fetched document
if let Some(frag) = parsed.fragment()
&& !frag.is_empty()
{
let pointer = if frag.starts_with('/') {
frag.to_string()
} else {
format!("/{frag}")
};
fetched_value = crate::core::refs::resolve_json_pointer(&fetched_value, &pointer)
.cloned()
.ok_or_else(|| {
SwaggerCliError::InvalidSpec(format!(
"fragment '{pointer}' not found in external ref '{resolved_url}'"
))
})?;
}
// Mark as visited for cycle detection, then recursively resolve nested external refs
visited.insert(resolved_url.clone());
// The base URL for nested refs is the URL of the document we just fetched (without fragment)
let nested_base = strip_fragment(&resolved_url);
resolve_recursive(
&mut fetched_value,
Some(&nested_base),
config,
client,
depth + 1,
visited,
stats,
)
.await?;
visited.remove(&resolved_url);
*value = fetched_value;
stats.refs_resolved += 1;
return Ok(());
}
// Walk into objects and arrays
match value {
Value::Object(map) => {
// Collect keys first to satisfy borrow checker with recursive async
let keys: Vec<String> = map.keys().cloned().collect();
for key in keys {
if let Some(val) = map.get_mut(&key) {
resolve_recursive(val, base_url, config, client, depth, visited, stats)
.await?;
}
}
}
Value::Array(arr) => {
for item in arr.iter_mut() {
resolve_recursive(item, base_url, config, client, depth, visited, stats)
.await?;
}
}
_ => {}
}
Ok(())
})
}
/// Extract the `$ref` string from a JSON object if present.
fn extract_external_ref(value: &Value) -> Option<String> {
let map = value.as_object()?;
let ref_val = map.get("$ref")?;
Some(ref_val.as_str()?.to_string())
}
/// Resolve a possibly-relative ref URL against a base URL.
fn resolve_ref_url(ref_str: &str, base_url: Option<&str>) -> Result<String, SwaggerCliError> {
// If the ref is already absolute, use it directly
if ref_str.contains("://") {
return Ok(ref_str.to_string());
}
// Relative ref requires a base URL
let base = base_url.ok_or_else(|| {
SwaggerCliError::InvalidSpec(format!(
"relative external ref '{ref_str}' cannot be resolved without a base URL"
))
})?;
let base_parsed = Url::parse(base)
.map_err(|e| SwaggerCliError::InvalidSpec(format!("invalid base URL '{base}': {e}")))?;
base_parsed
.join(ref_str)
.map(|u| u.to_string())
.map_err(|e| {
SwaggerCliError::InvalidSpec(format!(
"failed to resolve relative ref '{ref_str}' against base '{base}': {e}"
))
})
}
/// Strip the fragment portion from a URL string.
fn strip_fragment(url: &str) -> String {
match url.find('#') {
Some(idx) => url[..idx].to_string(),
None => url.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// -- URL resolution -------------------------------------------------------
#[test]
fn test_resolve_absolute_ref() {
let result = resolve_ref_url("https://example.com/schemas/Pet.json", None).unwrap();
assert_eq!(result, "https://example.com/schemas/Pet.json");
}
#[test]
fn test_resolve_relative_ref() {
let result = resolve_ref_url(
"./schemas/Pet.json",
Some("https://example.com/api/spec.json"),
)
.unwrap();
assert_eq!(result, "https://example.com/api/schemas/Pet.json");
}
#[test]
fn test_resolve_relative_parent() {
let result = resolve_ref_url(
"../schemas/Pet.json",
Some("https://example.com/api/v1/spec.json"),
)
.unwrap();
assert_eq!(result, "https://example.com/api/schemas/Pet.json");
}
#[test]
fn test_resolve_relative_without_base_fails() {
let result = resolve_ref_url("./schemas/Pet.json", None);
assert!(result.is_err());
}
// -- Fragment stripping ---------------------------------------------------
#[test]
fn test_strip_fragment() {
assert_eq!(
strip_fragment("https://example.com/spec.json#/components/schemas/Pet"),
"https://example.com/spec.json"
);
assert_eq!(
strip_fragment("https://example.com/spec.json"),
"https://example.com/spec.json"
);
}
// -- External ref extraction ----------------------------------------------
#[test]
fn test_extract_external_ref_present() {
let v = json!({"$ref": "https://example.com/Pet.json"});
assert_eq!(
extract_external_ref(&v),
Some("https://example.com/Pet.json".to_string())
);
}
#[test]
fn test_extract_external_ref_internal() {
let v = json!({"$ref": "#/components/schemas/Pet"});
assert_eq!(
extract_external_ref(&v),
Some("#/components/schemas/Pet".to_string())
);
}
#[test]
fn test_extract_external_ref_absent() {
let v = json!({"type": "string"});
assert_eq!(extract_external_ref(&v), None);
}
// -- Integration tests with mockito ---------------------------------------
#[tokio::test]
async fn test_resolve_external_ref_allowed_host() {
let mut server = mockito::Server::new_async().await;
let host = server.host_with_port();
let pet_schema = json!({
"type": "object",
"properties": {
"name": { "type": "string" }
}
});
let _mock = server
.mock("GET", "/schemas/Pet.json")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&pet_schema).unwrap())
.create_async()
.await;
let spec_url = format!("http://{host}/api/spec.json");
let ref_url = format!("http://{host}/schemas/Pet.json");
let mut value = json!({
"openapi": "3.0.3",
"components": {
"schemas": {
"Pet": { "$ref": ref_url }
}
}
});
let hostname = host.split(':').next().unwrap().to_string();
let config = ExternalRefConfig {
allow_hosts: vec![hostname.clone()],
max_depth: 5,
max_bytes: 1_048_576,
};
let client = AsyncHttpClient::builder()
.allow_insecure_http(true)
.allowed_private_hosts(vec![hostname.clone()])
.build();
let stats = resolve_external_refs(&mut value, Some(&spec_url), &config, &client)
.await
.unwrap();
assert_eq!(stats.refs_resolved, 1);
assert_eq!(value["components"]["schemas"]["Pet"]["type"], "object");
assert_eq!(
value["components"]["schemas"]["Pet"]["properties"]["name"]["type"],
"string"
);
}
#[tokio::test]
async fn test_resolve_external_ref_disallowed_host() {
let mut value = json!({
"schema": { "$ref": "https://evil.example.com/schemas/Pet.json" }
});
let config = ExternalRefConfig {
allow_hosts: vec!["safe.example.com".to_string()],
max_depth: 5,
max_bytes: 1_048_576,
};
let client = AsyncHttpClient::builder().build();
let result = resolve_external_refs(&mut value, None, &config, &client).await;
assert!(result.is_err());
match result.unwrap_err() {
SwaggerCliError::PolicyBlocked(msg) => {
assert!(msg.contains("evil.example.com"));
assert!(msg.contains("--ref-allow-host"));
}
other => panic!("expected PolicyBlocked, got: {other:?}"),
}
}
#[tokio::test]
async fn test_resolve_internal_refs_untouched() {
let mut value = json!({
"schema": { "$ref": "#/components/schemas/Pet" }
});
let config = ExternalRefConfig {
allow_hosts: vec![],
max_depth: 5,
max_bytes: 1_048_576,
};
let client = AsyncHttpClient::builder().build();
let stats = resolve_external_refs(&mut value, None, &config, &client)
.await
.unwrap();
assert_eq!(stats.refs_resolved, 0);
assert_eq!(value["schema"]["$ref"], "#/components/schemas/Pet");
}
#[tokio::test]
async fn test_resolve_max_depth_limits_chains() {
let mut server = mockito::Server::new_async().await;
let host = server.host_with_port();
// Chain: spec -> A.json -> B.json
// With max_depth=1, only A.json should be resolved
let b_schema = json!({
"type": "string",
"from": "B"
});
let _mock_b = server
.mock("GET", "/B.json")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&b_schema).unwrap())
.create_async()
.await;
let a_schema = json!({
"type": "object",
"nested": { "$ref": format!("http://{host}/B.json") }
});
let _mock_a = server
.mock("GET", "/A.json")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&a_schema).unwrap())
.create_async()
.await;
let ref_url = format!("http://{host}/A.json");
let mut value = json!({
"schema": { "$ref": ref_url }
});
let hostname = host.split(':').next().unwrap().to_string();
let config = ExternalRefConfig {
allow_hosts: vec![hostname.clone()],
max_depth: 1, // Only one level deep
max_bytes: 1_048_576,
};
let client = AsyncHttpClient::builder()
.allow_insecure_http(true)
.allowed_private_hosts(vec![hostname.clone()])
.build();
let stats = resolve_external_refs(&mut value, None, &config, &client)
.await
.unwrap();
// A was resolved (depth 0), B was skipped (depth 1 >= max_depth 1)
assert_eq!(stats.refs_resolved, 1);
assert_eq!(stats.refs_skipped, 1);
assert_eq!(value["schema"]["type"], "object");
}
#[tokio::test]
async fn test_resolve_circular_external_refs() {
let mut server = mockito::Server::new_async().await;
let host = server.host_with_port();
// A.json refs B.json, B.json refs A.json
let b_schema = json!({
"type": "object",
"back": { "$ref": format!("http://{host}/A.json") }
});
let _mock_b = server
.mock("GET", "/B.json")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&b_schema).unwrap())
.create_async()
.await;
let a_schema = json!({
"type": "object",
"next": { "$ref": format!("http://{host}/B.json") }
});
let _mock_a = server
.mock("GET", "/A.json")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&a_schema).unwrap())
.create_async()
.await;
let ref_url = format!("http://{host}/A.json");
let mut value = json!({
"schema": { "$ref": ref_url }
});
let hostname = host.split(':').next().unwrap().to_string();
let config = ExternalRefConfig {
allow_hosts: vec![hostname.clone()],
max_depth: 10,
max_bytes: 1_048_576,
};
let client = AsyncHttpClient::builder()
.allow_insecure_http(true)
.allowed_private_hosts(vec![hostname.clone()])
.build();
let stats = resolve_external_refs(&mut value, None, &config, &client)
.await
.unwrap();
// A and B resolved, but the circular back-ref to A was detected
assert_eq!(stats.refs_resolved, 2);
assert_eq!(stats.refs_skipped, 1);
assert!(
value["schema"]["next"]["back"]
.get("$circular_external_ref")
.is_some()
);
}
#[tokio::test]
async fn test_resolve_max_bytes_exceeded() {
let mut server = mockito::Server::new_async().await;
let host = server.host_with_port();
let large_body = "x".repeat(500);
let _mock = server
.mock("GET", "/big.json")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!("{{\"data\": \"{large_body}\"}}"))
.create_async()
.await;
let ref_url = format!("http://{host}/big.json");
let mut value = json!({
"schema": { "$ref": ref_url }
});
let hostname = host.split(':').next().unwrap().to_string();
let config = ExternalRefConfig {
allow_hosts: vec![hostname.clone()],
max_depth: 5,
max_bytes: 100, // Very small limit
};
let client = AsyncHttpClient::builder()
.allow_insecure_http(true)
.allowed_private_hosts(vec![hostname.clone()])
.build();
let result = resolve_external_refs(&mut value, None, &config, &client).await;
assert!(result.is_err());
match result.unwrap_err() {
SwaggerCliError::PolicyBlocked(msg) => {
assert!(msg.contains("--ref-max-bytes"));
}
other => panic!("expected PolicyBlocked, got: {other:?}"),
}
}
#[tokio::test]
async fn test_resolve_relative_ref_integration() {
let mut server = mockito::Server::new_async().await;
let host = server.host_with_port();
let pet_schema = json!({
"type": "object",
"properties": {
"name": { "type": "string" }
}
});
let _mock = server
.mock("GET", "/api/schemas/Pet.json")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&pet_schema).unwrap())
.create_async()
.await;
let base_url = format!("http://{host}/api/spec.json");
let mut value = json!({
"schema": { "$ref": "./schemas/Pet.json" }
});
let hostname = host.split(':').next().unwrap().to_string();
let config = ExternalRefConfig {
allow_hosts: vec![hostname.clone()],
max_depth: 5,
max_bytes: 1_048_576,
};
let client = AsyncHttpClient::builder()
.allow_insecure_http(true)
.allowed_private_hosts(vec![hostname.clone()])
.build();
let stats = resolve_external_refs(&mut value, Some(&base_url), &config, &client)
.await
.unwrap();
assert_eq!(stats.refs_resolved, 1);
assert_eq!(value["schema"]["type"], "object");
}
}

View File

@@ -1,6 +1,7 @@
pub mod cache;
pub mod config;
pub mod diff;
pub mod external_refs;
pub mod http;
pub mod indexer;
pub mod network;

View File

@@ -63,10 +63,12 @@ fn expand_recursive(
let pointer = &ref_str[1..]; // strip leading '#'
if let Some(resolved) = resolve_json_pointer(root, pointer) {
let mut expanded = resolved.clone();
visited.insert(ref_str);
visited.insert(ref_str.clone());
expand_recursive(&mut expanded, root, max_depth, depth + 1, visited);
// Do not remove from visited: keep it for sibling detection within the same
// subtree path. The caller manages the visited set across siblings.
// Remove after expansion so sibling subtrees can also expand this ref.
// The ancestor path (tracked via depth) still prevents true circular refs
// because the ref is in `visited` during its own subtree's expansion.
visited.remove(&ref_str);
*value = expanded;
}
// If pointer doesn't resolve, leave the $ref as-is (broken ref)
@@ -292,4 +294,58 @@ mod tests {
// Broken internal ref left untouched
assert_eq!(value, original);
}
#[test]
fn test_expand_shared_refs_both_expand() {
// Two sibling subtrees reference the same schema. Both should expand
// correctly -- shared refs are NOT circular.
let root = json!({
"components": {
"schemas": {
"Pet": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
}
}
});
let mut value = json!({
"requestBody": {
"schema": { "$ref": "#/components/schemas/Pet" }
},
"response": {
"schema": { "$ref": "#/components/schemas/Pet" }
}
});
expand_refs(&mut value, &root, 5);
// Both should be fully expanded (not marked as $circular_ref)
assert_eq!(value["requestBody"]["schema"]["type"], "object");
assert_eq!(
value["requestBody"]["schema"]["properties"]["name"]["type"],
"string"
);
assert_eq!(value["response"]["schema"]["type"], "object");
assert_eq!(
value["response"]["schema"]["properties"]["name"]["type"],
"string"
);
// Neither should have $circular_ref
assert!(
value["requestBody"]["schema"]
.get("$circular_ref")
.is_none(),
"requestBody ref should not be marked circular"
);
assert!(
value["response"]["schema"].get("$circular_ref").is_none(),
"response ref should not be marked circular"
);
}
}

View File

@@ -26,7 +26,7 @@ pub enum SearchResultType {
}
impl SearchResultType {
fn ordinal(self) -> u8 {
pub(crate) fn ordinal(self) -> u8 {
match self {
Self::Endpoint => 0,
Self::Schema => 1,

View File

@@ -0,0 +1,168 @@
//! Crash consistency tests for the cache write protocol.
//!
//! Simulates partial writes at each stage of the crash-consistent write protocol
//! and verifies that the read protocol detects corruption and doctor --fix repairs it.
mod helpers;
use std::fs;
/// Helper: fetch a spec into the test environment so we have valid cache state.
fn setup_with_cached_spec(env: &helpers::TestEnv) {
helpers::fetch_fixture(env, "petstore.json", "petstore");
}
/// When raw.json exists but meta.json is missing (crash before commit marker),
/// load_index should fail with AliasNotFound (meta is the commit marker).
#[test]
fn test_crash_before_meta_write() {
let env = helpers::TestEnv::new();
setup_with_cached_spec(&env);
let alias_dir = env.cache_dir.join("petstore");
let meta_path = alias_dir.join("meta.json");
// Simulate crash: remove meta.json (the commit marker)
assert!(meta_path.exists(), "meta.json should exist after fetch");
fs::remove_file(&meta_path).expect("failed to remove meta.json");
// Read should fail — no commit marker
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
result.failure();
}
/// When meta.json exists but index.json is corrupted (partial write),
/// load_index should fail with CacheIntegrity (hash mismatch).
#[test]
fn test_crash_corrupted_index() {
let env = helpers::TestEnv::new();
setup_with_cached_spec(&env);
let alias_dir = env.cache_dir.join("petstore");
let index_path = alias_dir.join("index.json");
// Corrupt the index file
fs::write(&index_path, b"{ corrupted }").expect("failed to corrupt index");
// Read should fail — hash mismatch
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
result.failure();
}
/// When meta.json exists but raw.json is corrupted,
/// load_raw should fail with CacheIntegrity (hash mismatch).
/// Note: list/search only need index.json, so we test via show which needs raw.json.
#[test]
fn test_crash_corrupted_raw_json() {
let env = helpers::TestEnv::new();
setup_with_cached_spec(&env);
let alias_dir = env.cache_dir.join("petstore");
let raw_path = alias_dir.join("raw.json");
// Corrupt raw.json
fs::write(&raw_path, b"{ corrupted }").expect("failed to corrupt raw.json");
// show needs raw.json for $ref expansion → should fail
let result = helpers::run_cmd(
&env,
&["show", "petstore", "/pets", "--method", "GET", "--robot"],
);
result.failure();
}
/// Doctor --fix should repair a missing index by rebuilding from raw.json.
#[test]
fn test_doctor_fix_repairs_missing_index() {
let env = helpers::TestEnv::new();
setup_with_cached_spec(&env);
let alias_dir = env.cache_dir.join("petstore");
let index_path = alias_dir.join("index.json");
// Remove index.json
fs::remove_file(&index_path).expect("failed to remove index.json");
// Doctor --fix should repair
let result = helpers::run_cmd(&env, &["doctor", "--fix", "--robot"]);
result.success();
// After fix, list should work again
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
result.success();
}
/// Doctor --fix should repair a corrupted index (hash mismatch) by rebuilding.
#[test]
fn test_doctor_fix_repairs_corrupted_index() {
let env = helpers::TestEnv::new();
setup_with_cached_spec(&env);
let alias_dir = env.cache_dir.join("petstore");
let index_path = alias_dir.join("index.json");
// Corrupt the index
fs::write(&index_path, b"[]").expect("failed to corrupt index");
// Doctor --fix should detect and repair
let result = helpers::run_cmd(&env, &["doctor", "--fix", "--robot"]);
result.success();
// After fix, list should work
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
result.success();
}
/// When meta.json has a generation mismatch with index.json, read should fail.
#[test]
fn test_generation_mismatch_detected() {
let env = helpers::TestEnv::new();
setup_with_cached_spec(&env);
let alias_dir = env.cache_dir.join("petstore");
let meta_path = alias_dir.join("meta.json");
// Tamper with generation in meta.json
let meta_bytes = fs::read(&meta_path).expect("failed to read meta");
let mut meta: serde_json::Value =
serde_json::from_slice(&meta_bytes).expect("failed to parse meta");
meta["generation"] = serde_json::json!(9999);
let tampered = serde_json::to_vec_pretty(&meta).expect("failed to serialize");
fs::write(&meta_path, &tampered).expect("failed to write tampered meta");
// Read should fail — generation mismatch
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
result.failure();
}
/// Leftover .tmp files from a crash should not interfere with normal operation.
#[test]
fn test_leftover_tmp_files_harmless() {
let env = helpers::TestEnv::new();
setup_with_cached_spec(&env);
let alias_dir = env.cache_dir.join("petstore");
// Create leftover .tmp files (simulating interrupted write)
fs::write(alias_dir.join("raw.json.tmp"), b"partial data").expect("failed to create tmp file");
fs::write(alias_dir.join("index.json.tmp"), b"partial index")
.expect("failed to create tmp file");
// Normal operations should still work
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
result.success();
}
/// A completely empty alias directory (no files at all) should be treated as not found.
#[test]
fn test_empty_alias_directory() {
let env = helpers::TestEnv::new();
// Create an empty directory that looks like an alias
let empty_alias_dir = env.cache_dir.join("ghost-alias");
fs::create_dir_all(&empty_alias_dir).expect("failed to create dir");
// Should fail — no meta.json
let result = helpers::run_cmd(&env, &["list", "ghost-alias", "--robot"]);
result.failure();
}

View File

@@ -0,0 +1,170 @@
//! Lock contention tests for cache write safety.
//!
//! Verifies that concurrent access to the same alias doesn't cause corruption
//! or panics. Uses multiple threads (not processes) for simplicity.
#![allow(deprecated)] // assert_cmd::Command::cargo_bin deprecation
mod helpers;
use std::sync::{Arc, Barrier};
use std::thread;
/// Multiple threads fetching the same alias concurrently should all succeed
/// or receive CACHE_LOCKED errors — never corruption.
///
/// After all threads complete, the final cache state should be valid.
#[test]
fn test_concurrent_fetch_no_corruption() {
let env = helpers::TestEnv::new();
let fixture_path = helpers::fixture_path("petstore.json");
let fixture_str = fixture_path.to_str().expect("fixture path not UTF-8");
// First, do an initial fetch so the alias exists
helpers::run_cmd(&env, &["fetch", fixture_str, "--alias", "petstore"]).success();
let thread_count = 8;
let barrier = Arc::new(Barrier::new(thread_count));
let home_dir = env.home_dir.clone();
let fixture = fixture_str.to_string();
let handles: Vec<_> = (0..thread_count)
.map(|_| {
let barrier = Arc::clone(&barrier);
let home = home_dir.clone();
let fix = fixture.clone();
thread::spawn(move || {
// Wait for all threads to be ready
barrier.wait();
// All threads try to re-fetch the same alias with --force
let output = assert_cmd::Command::cargo_bin("swagger-cli")
.expect("binary not found")
.env("SWAGGER_CLI_HOME", &home)
.args(["fetch", &fix, "--alias", "petstore", "--force"])
.output()
.expect("failed to execute");
output.status.code().unwrap_or(-1)
})
})
.collect();
let exit_codes: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// All should either succeed (0) or get CACHE_LOCKED (exit code 9)
for (i, code) in exit_codes.iter().enumerate() {
assert!(
*code == 0 || *code == 9,
"thread {i} exited with unexpected code {code} (expected 0 or 9)"
);
}
// At least one should succeed
assert!(
exit_codes.contains(&0),
"at least one thread should succeed"
);
// Final state should be valid — list should work
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
result.success();
// Doctor should report healthy
let result = helpers::run_cmd(&env, &["doctor", "--robot"]);
let a = result.success();
let json = helpers::parse_robot_json(&a.get_output().stdout);
// Doctor output should indicate health
assert!(
json["data"]["health"].as_str().is_some(),
"doctor should report health status"
);
}
/// Multiple threads listing the same alias concurrently should all succeed.
/// Reads are not locked (only writes acquire the lock).
#[test]
fn test_concurrent_reads_no_contention() {
let env = helpers::TestEnv::new();
helpers::fetch_fixture(&env, "petstore.json", "petstore");
let thread_count = 16;
let barrier = Arc::new(Barrier::new(thread_count));
let home_dir = env.home_dir.clone();
let handles: Vec<_> = (0..thread_count)
.map(|_| {
let barrier = Arc::clone(&barrier);
let home = home_dir.clone();
thread::spawn(move || {
barrier.wait();
let output = assert_cmd::Command::cargo_bin("swagger-cli")
.expect("binary not found")
.env("SWAGGER_CLI_HOME", &home)
.args(["list", "petstore", "--robot"])
.output()
.expect("failed to execute");
output.status.code().unwrap_or(-1)
})
})
.collect();
let exit_codes: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// ALL reads should succeed (no locking on reads)
for (i, code) in exit_codes.iter().enumerate() {
assert_eq!(*code, 0, "thread {i} read failed with exit code {code}");
}
}
/// Concurrent writes to DIFFERENT aliases should never interfere with each other.
#[test]
fn test_concurrent_different_aliases() {
let env = helpers::TestEnv::new();
let fixture_path = helpers::fixture_path("petstore.json");
let fixture_str = fixture_path.to_str().expect("fixture path not UTF-8");
let aliases = ["alpha", "bravo", "charlie", "delta"];
let barrier = Arc::new(Barrier::new(aliases.len()));
let home_dir = env.home_dir.clone();
let fixture = fixture_str.to_string();
let handles: Vec<_> = aliases
.iter()
.map(|alias| {
let barrier = Arc::clone(&barrier);
let home = home_dir.clone();
let fix = fixture.clone();
let a = alias.to_string();
thread::spawn(move || {
barrier.wait();
let output = assert_cmd::Command::cargo_bin("swagger-cli")
.expect("binary not found")
.env("SWAGGER_CLI_HOME", &home)
.args(["fetch", &fix, "--alias", &a])
.output()
.expect("failed to execute");
(a, output.status.code().unwrap_or(-1))
})
})
.collect();
let results: Vec<(String, i32)> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// All should succeed — different aliases means different lock files
for (alias, code) in &results {
assert_eq!(*code, 0, "fetch to alias '{alias}' failed with code {code}");
}
// All aliases should be listable
for alias in &aliases {
helpers::run_cmd(&env, &["list", alias, "--robot"]).success();
}
}

133
tests/property_test.rs Normal file
View File

@@ -0,0 +1,133 @@
//! Property-based tests for deterministic behavior.
//!
//! Uses proptest to verify that hashing, JSON serialization, and spec construction
//! produce deterministic results regardless of input data.
use proptest::prelude::*;
use serde_json::json;
use sha2::{Digest, Sha256};
// The SHA-256 hash function is deterministic: same bytes always produce the same hash.
proptest! {
#[test]
fn hash_deterministic(data in proptest::collection::vec(any::<u8>(), 0..10000)) {
let mut h1 = Sha256::new();
h1.update(&data);
let r1 = format!("{:x}", h1.finalize());
let mut h2 = Sha256::new();
h2.update(&data);
let r2 = format!("{:x}", h2.finalize());
prop_assert_eq!(r1, r2);
}
}
// Two specs with the same paths (inserted in different order) should produce
// identical canonical JSON after a parse-serialize roundtrip through serde_json::Value.
//
// serde_json::Value uses BTreeMap for objects, so key ordering is deterministic
// regardless of insertion order.
proptest! {
#![proptest_config(ProptestConfig::with_cases(20))]
#[test]
fn index_ordering_deterministic(
// Use proptest to pick a permutation index
perm_seed in 0..40320u32, // 8! = 40320
) {
let paths: Vec<(&str, &str, &str)> = vec![
("/pets", "get", "List pets"),
("/pets", "post", "Create pet"),
("/pets/{petId}", "get", "Get pet"),
("/pets/{petId}", "delete", "Delete pet"),
("/users", "get", "List users"),
("/users/{userId}", "get", "Get user"),
("/orders", "get", "List orders"),
("/orders", "post", "Create order"),
];
// Build the spec with original order
let spec1 = build_spec_from_paths(&paths);
// Generate a deterministic permutation from the seed
let shuffled = permute(&paths, perm_seed);
let spec2 = build_spec_from_paths(&shuffled);
// After roundtripping through serde_json::Value (BTreeMap ordering),
// both should produce identical canonical JSON.
let val1: serde_json::Value = serde_json::from_str(&serde_json::to_string(&spec1).unwrap()).unwrap();
let val2: serde_json::Value = serde_json::from_str(&serde_json::to_string(&spec2).unwrap()).unwrap();
let canonical1 = serde_json::to_string(&val1).unwrap();
let canonical2 = serde_json::to_string(&val2).unwrap();
prop_assert_eq!(canonical1, canonical2);
}
}
/// Deterministic permutation using factorial number system (no rand dependency).
fn permute<T: Clone>(items: &[T], mut seed: u32) -> Vec<T> {
let mut remaining: Vec<T> = items.to_vec();
let mut result = Vec::with_capacity(items.len());
for i in (1..=items.len()).rev() {
let idx = (seed % i as u32) as usize;
seed /= i as u32;
result.push(remaining.remove(idx));
}
result
}
/// Build a minimal OpenAPI spec from a list of (path, method, summary) tuples.
fn build_spec_from_paths(paths: &[(&str, &str, &str)]) -> serde_json::Value {
let mut path_map = serde_json::Map::new();
for (path, method, summary) in paths {
let path_entry = path_map
.entry(path.to_string())
.or_insert_with(|| json!({}));
path_entry[method] = json!({
"summary": summary,
"responses": { "200": { "description": "OK" } }
});
}
json!({
"openapi": "3.0.3",
"info": { "title": "Test", "version": "1.0.0" },
"paths": path_map
})
}
// Verify that JSON canonical form is stable across parse-serialize cycles.
proptest! {
#[test]
fn json_roundtrip_stable(
key1 in "[a-z]{1,10}",
key2 in "[a-z]{1,10}",
val1 in any::<i64>(),
val2 in any::<i64>(),
) {
let obj = json!({ key1.clone(): val1, key2.clone(): val2 });
let s1 = serde_json::to_string(&obj).unwrap();
let reparsed: serde_json::Value = serde_json::from_str(&s1).unwrap();
let s2 = serde_json::to_string(&reparsed).unwrap();
// Parse -> serialize should be idempotent
prop_assert_eq!(s1, s2);
}
}
// Hash output format is always "sha256:" followed by 64 hex chars.
proptest! {
#[test]
fn hash_format_consistent(data in proptest::collection::vec(any::<u8>(), 0..1000)) {
let mut hasher = Sha256::new();
hasher.update(&data);
let hash = format!("sha256:{:x}", hasher.finalize());
prop_assert!(hash.starts_with("sha256:"));
// SHA-256 hex is 64 chars
prop_assert_eq!(hash.len(), 7 + 64); // "sha256:" + 64 hex digits
}
}