use crate::cli::{CapabilityArg, PythonArgs, ThresholdArg};
use crate::core::analyzers::uv::{Capability, ScopeIndex, Uv};
use crate::core::error::{ErrorCode, Result, UpkeepError};
use crate::core::output::print_json;
use crate::core::python::{
PythonCapability, PythonCapabilityCoverage, PythonManager, PythonManagerName, PythonOutput,
PythonSeverity, PythonUnavailableCapability, PythonUnavailableReason, PYTHON_SCHEMA_VERSION,
};
const UPSTREAM_DISCLAIMER: &str =
"uv documents its own JSON output as unstable; this report is normalized into cargo-upkeep \
schema_version 1";
pub async fn run(json: bool, args: PythonArgs) -> Result<()> {
let policy = ExitPolicy::from(&args);
let working_directory = std::env::current_dir().map_err(|err| {
UpkeepError::context(ErrorCode::Io, "failed to read the working directory", err)
})?;
let uv = Uv::detect(&working_directory).await?;
let output = build_output(&uv).await;
emit_output(json, &output)?;
enforce_exit_policy(&output, &policy)
}
fn emit_output(json: bool, output: &PythonOutput) -> Result<()> {
if json {
print_json(output)
} else {
println!("{output}");
Ok(())
}
}
async fn build_output(uv: &Uv) -> PythonOutput {
let mut warnings = vec![UPSTREAM_DISCLAIMER.to_string()];
let mut unavailable = Vec::new();
let (outdated, scopes) = match uv.probe_outdated().await {
Capability::Available => match uv.outdated().await {
Ok((report, scopes)) => (Some(report), scopes),
Err(err) => {
unavailable.push(gap(
PythonCapability::Outdated,
PythonUnavailableReason::Failed,
err.to_string(),
));
(None, ScopeIndex::default())
}
},
Capability::Unavailable { reason, detail } => {
unavailable.push(gap(PythonCapability::Outdated, reason, detail));
(None, ScopeIndex::default())
}
};
let security = match uv.probe_security().await {
Capability::Available => match uv.security(&scopes).await {
Ok((report, notes)) => {
warnings.extend(notes);
Some(report)
}
Err(err) => {
unavailable.push(gap(
PythonCapability::Security,
PythonUnavailableReason::Failed,
err.to_string(),
));
None
}
},
Capability::Unavailable { reason, detail } => {
unavailable.push(gap(PythonCapability::Security, reason, detail));
None
}
};
let capabilities = coverage(&unavailable);
PythonOutput {
schema_version: PYTHON_SCHEMA_VERSION,
manager: PythonManager {
name: PythonManagerName::Uv,
version: uv.version().map(str::to_string),
},
complete: unavailable.is_empty(),
capabilities,
unavailable,
outdated,
security,
warnings,
}
}
fn coverage(unavailable: &[PythonUnavailableCapability]) -> Vec<PythonCapabilityCoverage> {
[PythonCapability::Outdated, PythonCapability::Security]
.into_iter()
.map(|name| PythonCapabilityCoverage {
name,
measured: !unavailable.iter().any(|entry| entry.name == name),
})
.collect()
}
fn gap(
name: PythonCapability,
reason: PythonUnavailableReason,
detail: String,
) -> PythonUnavailableCapability {
PythonUnavailableCapability {
name,
reason,
detail,
}
}
#[derive(Debug, Default)]
struct ExitPolicy {
require_complete: Option<Vec<PythonCapability>>,
fail_on_vulnerability: Option<ThresholdArg>,
}
impl From<&PythonArgs> for ExitPolicy {
fn from(args: &PythonArgs) -> Self {
Self {
require_complete: args.require_complete.as_ref().map(|capabilities| {
capabilities
.iter()
.map(|capability| match capability {
CapabilityArg::Outdated => PythonCapability::Outdated,
CapabilityArg::Security => PythonCapability::Security,
})
.collect()
}),
fail_on_vulnerability: args.fail_on_vulnerability,
}
}
}
fn enforce_exit_policy(output: &PythonOutput, policy: &ExitPolicy) -> Result<()> {
if output.outdated.is_none() && output.security.is_none() {
return Err(UpkeepError::message(
ErrorCode::IncompleteAnalysis,
format!(
"python analysis measured nothing: all {} capabilities were unavailable, so there \
is no report to stand on; each entry in `unavailable` says why",
output.capabilities.len()
),
));
}
if let Some(required) = &policy.require_complete {
let missing: Vec<String> = if required.is_empty() {
output
.unavailable
.iter()
.map(|gap| gap.name.to_string())
.collect()
} else {
required
.iter()
.filter(|name| output.unavailable.iter().any(|gap| gap.name == **name))
.map(ToString::to_string)
.collect()
};
if !missing.is_empty() {
return Err(UpkeepError::message(
ErrorCode::IncompleteAnalysis,
format!(
"python analysis incomplete: {} was not measured; --require-complete treats \
that as a failure, and the report says why",
missing.join(", ")
),
));
}
}
if let Some(threshold) = policy.fail_on_vulnerability {
if let Some(security) = &output.security {
let matched = security
.findings
.iter()
.filter(|finding| meets_threshold(finding.severity, threshold))
.count();
if matched > 0 {
let all_unknown = security
.findings
.iter()
.all(|finding| finding.severity == PythonSeverity::Unknown);
let reason = if all_unknown {
"every finding has an unknown severity, which satisfies every \
threshold because it cannot be shown to be below the bar"
.to_string()
} else {
format!("they are at or above `{}`", threshold_label(threshold))
};
return Err(UpkeepError::message(
ErrorCode::PolicyViolation,
format!(
"{matched} of {} vulnerabilities match `{}`; {reason}; \
--fail-on-vulnerability treats that as a failure",
security.summary.total,
threshold_label(threshold),
),
));
}
}
}
Ok(())
}
fn meets_threshold(severity: PythonSeverity, threshold: ThresholdArg) -> bool {
if severity == PythonSeverity::Unknown {
return true;
}
match threshold {
ThresholdArg::Critical => severity == PythonSeverity::Critical,
ThresholdArg::High => matches!(severity, PythonSeverity::Critical | PythonSeverity::High),
ThresholdArg::Moderate => matches!(
severity,
PythonSeverity::Critical | PythonSeverity::High | PythonSeverity::Moderate
),
ThresholdArg::Low | ThresholdArg::Any => true,
}
}
fn threshold_label(threshold: ThresholdArg) -> &'static str {
match threshold {
ThresholdArg::Critical => "critical",
ThresholdArg::High => "high",
ThresholdArg::Moderate => "moderate",
ThresholdArg::Low => "low",
ThresholdArg::Any => "any",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::python::{
PythonDependencyScope, PythonOutdatedReport, PythonSecurityReport, PythonSecuritySummary,
PythonUpdateCounts, PythonVulnerability,
};
fn empty_outdated() -> PythonOutdatedReport {
PythonOutdatedReport {
checked: 4,
outdated: 0,
counts: PythonUpdateCounts {
epoch: 0,
major: 0,
minor: 0,
patch: 0,
qualifier: 0,
unclassified: 0,
},
packages: Vec::new(),
}
}
fn finding(severity: PythonSeverity) -> PythonVulnerability {
PythonVulnerability {
id: "GHSA-0000-0000-0000".to_string(),
aliases: None,
package: "example".to_string(),
installed_version: "1.0.0".to_string(),
severity,
title: None,
scope: PythonDependencyScope::Unknown,
fixed_versions: None,
}
}
fn security_report(severities: &[PythonSeverity]) -> PythonSecurityReport {
let mut summary = PythonSecuritySummary {
critical: 0,
high: 0,
moderate: 0,
low: 0,
unknown: 0,
total: severities.len(),
};
for severity in severities {
match severity {
PythonSeverity::Critical => summary.critical += 1,
PythonSeverity::High => summary.high += 1,
PythonSeverity::Moderate => summary.moderate += 1,
PythonSeverity::Low => summary.low += 1,
PythonSeverity::Unknown => summary.unknown += 1,
}
}
PythonSecurityReport {
summary,
findings: severities.iter().copied().map(finding).collect(),
}
}
fn output(
outdated: Option<PythonOutdatedReport>,
security: Option<PythonSecurityReport>,
) -> PythonOutput {
let unavailable: Vec<PythonUnavailableCapability> = [
(PythonCapability::Outdated, outdated.is_none()),
(PythonCapability::Security, security.is_none()),
]
.into_iter()
.filter(|(_, missing)| *missing)
.map(|(name, _)| {
gap(
name,
PythonUnavailableReason::NotInstalled,
"not under test".to_string(),
)
})
.collect();
PythonOutput {
schema_version: PYTHON_SCHEMA_VERSION,
manager: PythonManager {
name: PythonManagerName::Uv,
version: Some("0.0.0".to_string()),
},
complete: unavailable.is_empty(),
capabilities: coverage(&unavailable),
unavailable,
outdated,
security,
warnings: Vec::new(),
}
}
fn policy(
require_complete: Option<Vec<PythonCapability>>,
fail_on_vulnerability: Option<ThresholdArg>,
) -> ExitPolicy {
ExitPolicy {
require_complete,
fail_on_vulnerability,
}
}
#[test]
fn findings_alone_exit_zero() {
let result = enforce_exit_policy(
&output(
Some(empty_outdated()),
Some(security_report(&[PythonSeverity::Critical])),
),
&ExitPolicy::default(),
);
assert!(
result.is_ok(),
"vulnerabilities alone must not fail the run"
);
}
#[test]
fn a_run_with_no_reports_fails_without_a_flag() {
let err = enforce_exit_policy(&output(None, None), &ExitPolicy::default())
.expect_err("nothing was measured, so this must not report success");
assert_eq!(err.code(), ErrorCode::IncompleteAnalysis);
assert!(err.to_string().contains("measured nothing"), "{err}");
}
#[test]
fn a_partial_run_exits_zero_without_require_complete() {
assert!(enforce_exit_policy(
&output(Some(empty_outdated()), None),
&ExitPolicy::default()
)
.is_ok());
}
#[test]
fn bare_require_complete_fails_on_any_gap() {
let err = enforce_exit_policy(
&output(Some(empty_outdated()), None),
&policy(Some(Vec::new()), None),
)
.expect_err("security was not measured");
assert_eq!(err.code(), ErrorCode::IncompleteAnalysis);
assert!(err.to_string().contains("security"), "{err}");
assert!(
enforce_exit_policy(
&output(Some(empty_outdated()), Some(security_report(&[]))),
&policy(Some(Vec::new()), None)
)
.is_ok(),
"a complete run satisfies the bare form"
);
}
#[test]
fn a_named_require_complete_ignores_capabilities_it_did_not_name() {
assert!(
enforce_exit_policy(
&output(Some(empty_outdated()), None),
&policy(Some(vec![PythonCapability::Outdated]), None)
)
.is_ok(),
"only `outdated` was required, and `outdated` was measured"
);
let err = enforce_exit_policy(
&output(Some(empty_outdated()), None),
&policy(Some(vec![PythonCapability::Security]), None),
)
.expect_err("security was required and not measured");
assert!(err.to_string().contains("security"), "{err}");
}
#[test]
fn thresholds_accept_at_or_above_and_always_accept_unknown() {
use PythonSeverity::{Critical, High, Low, Moderate, Unknown};
use ThresholdArg as T;
for (severity, expected) in [
(Critical, [true, true, true, true, true]),
(High, [false, true, true, true, true]),
(Moderate, [false, false, true, true, true]),
(Low, [false, false, false, true, true]),
(Unknown, [true, true, true, true, true]),
] {
for (threshold, expected) in [T::Critical, T::High, T::Moderate, T::Low, T::Any]
.into_iter()
.zip(expected)
{
assert_eq!(
meets_threshold(severity, threshold),
expected,
"{severity} against {}",
threshold_label(threshold)
);
}
}
}
#[test]
fn fail_on_vulnerability_uses_the_policy_violation_code() {
let err = enforce_exit_policy(
&output(
Some(empty_outdated()),
Some(security_report(&[PythonSeverity::Moderate])),
),
&policy(None, Some(ThresholdArg::Moderate)),
)
.expect_err("a moderate finding trips a moderate threshold");
assert_eq!(
err.code(),
ErrorCode::PolicyViolation,
"an opt-in gate rejecting a result is not an incomplete analysis"
);
assert!(err.to_string().contains("moderate"), "{err}");
assert!(
enforce_exit_policy(
&output(
Some(empty_outdated()),
Some(security_report(&[PythonSeverity::Moderate]))
),
&policy(None, Some(ThresholdArg::High))
)
.is_ok(),
"a moderate finding is below a high threshold"
);
}
#[test]
fn an_unknown_severity_trips_even_the_critical_threshold() {
let err = enforce_exit_policy(
&output(
Some(empty_outdated()),
Some(security_report(&[PythonSeverity::Unknown])),
),
&policy(None, Some(ThresholdArg::Critical)),
)
.expect_err("a severity that was never established cannot be shown to be below the bar");
assert_eq!(err.code(), ErrorCode::PolicyViolation);
}
#[test]
fn fail_on_vulnerability_says_nothing_about_an_unmeasured_scanner() {
assert!(enforce_exit_policy(
&output(Some(empty_outdated()), None),
&policy(None, Some(ThresholdArg::Any))
)
.is_ok());
let err = enforce_exit_policy(
&output(Some(empty_outdated()), None),
&policy(
Some(vec![PythonCapability::Security]),
Some(ThresholdArg::Any),
),
)
.expect_err("the coverage gate is the one that fires");
assert_eq!(err.code(), ErrorCode::IncompleteAnalysis);
}
#[test]
fn coverage_failures_are_reported_before_findings() {
let err = enforce_exit_policy(
&output(None, Some(security_report(&[PythonSeverity::Critical]))),
&policy(Some(Vec::new()), Some(ThresholdArg::Critical)),
)
.expect_err("both gates would fire");
assert_eq!(
err.code(),
ErrorCode::IncompleteAnalysis,
"a caller whose scanner never ran should hear that first"
);
}
#[test]
fn every_capability_is_listed_and_a_gap_marks_it_unmeasured() {
let none_missing = coverage(&[]);
assert_eq!(none_missing.len(), 2);
assert!(none_missing.iter().all(|capability| capability.measured));
let security_missing = coverage(&[gap(
PythonCapability::Security,
PythonUnavailableReason::NotInstalled,
"not under test".to_string(),
)]);
assert_eq!(
security_missing.len(),
2,
"an unmeasured capability must stay listed, not vanish"
);
assert!(
security_missing
.iter()
.any(|capability| capability.name == PythonCapability::Outdated
&& capability.measured)
);
assert!(security_missing.iter().any(|capability| capability.name
== PythonCapability::Security
&& !capability.measured));
let output = output(Some(empty_outdated()), None);
assert!(output.security.is_none());
assert!(!output.complete);
assert_eq!(output.unavailable[0].name, PythonCapability::Security);
}
}