use async_trait::async_trait;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::models::api_token::{ApiToken, TokenScope};
use crate::models::attestation::{UserPublicKey, UserPublicKeyWithUsage};
use crate::models::audit_log::{AuditEventType, AuditLog};
use crate::models::cloud_fixture::CloudFixture;
use crate::models::cloud_service::CloudService;
use crate::models::cloud_workspace::Workspace as CloudWorkspace;
use crate::models::feature_usage::FeatureType;
use crate::models::federation::Federation;
use crate::models::federation_scenario_activation::FederationScenarioActivation;
use crate::models::hosted_mock::{DeploymentStatus, HealthStatus, HostedMock};
use crate::models::org_template::OrgTemplate;
use crate::models::organization::{OrgMember, OrgRole, Organization, Plan};
use crate::models::osv::{OsvImportRecord, OsvMatch};
use crate::models::plugin::{PendingScanJob, Plugin, PluginSecurityScan, PluginVersion};
use crate::models::review::Review;
use crate::models::saml_assertion::SAMLAssertionId;
use crate::models::scenario::Scenario;
use crate::models::scenario_review::ScenarioReview;
use crate::models::settings::OrgSetting;
use crate::models::sso::{SSOConfiguration, SSOProvider};
use crate::models::subscription::UsageCounter;
use crate::models::suspicious_activity::{SuspiciousActivity, SuspiciousActivityType};
use crate::models::template::{Template, TemplateCategory};
use crate::models::template_review::TemplateReview;
use crate::models::user::User;
use crate::models::verification_token::VerificationToken;
use crate::models::waitlist::WaitlistSubscriber;
#[cfg(feature = "postgres")]
pub mod postgres;
#[cfg(feature = "postgres")]
pub use postgres::PgRegistryStore;
#[cfg(feature = "sqlite")]
pub mod sqlite;
#[cfg(feature = "sqlite")]
pub use sqlite::SqliteRegistryStore;
pub use crate::error::{StoreError, StoreResult};
pub fn version_affected(affected: &serde_json::Value, version: &str) -> bool {
version_affected_in_ecosystem(affected, version, "")
}
pub fn version_affected_in_ecosystem(
affected: &serde_json::Value,
version: &str,
ecosystem: &str,
) -> bool {
let normalized_target = normalize_version_for_ecosystem(ecosystem, version);
let effective_version = normalized_target.as_deref().unwrap_or(version);
if let Some(arr) = affected.get("versions").and_then(|v| v.as_array()) {
for v in arr.iter().filter_map(|v| v.as_str()) {
if v.eq_ignore_ascii_case(version) || v.eq_ignore_ascii_case(effective_version) {
return true;
}
if let Some(norm) = normalize_version_for_ecosystem(ecosystem, v) {
if norm.eq_ignore_ascii_case(effective_version) {
return true;
}
}
}
}
let Some(ranges) = affected.get("ranges").and_then(|v| v.as_array()) else {
return false;
};
let target = semver::Version::parse(effective_version).ok();
for r in ranges {
let range_type =
r.get("type").and_then(|v| v.as_str()).unwrap_or("SEMVER").to_ascii_uppercase();
if range_type == "GIT" {
continue;
}
let events = match r.get("events").and_then(|v| v.as_array()) {
Some(ev) => ev,
None => continue,
};
let normalized: Vec<(serde_json::Value, Option<String>)> = events
.iter()
.map(|e| {
let norm = event_kinds(e)
.into_iter()
.find_map(|k| event_value(e, k))
.and_then(|raw| normalize_version_for_ecosystem(ecosystem, &raw));
(e.clone(), norm)
})
.collect();
let sorted = sort_events(events);
let mut intro: Option<String> = None;
let mut matched = false;
for e in &sorted {
let norm_version =
normalized.iter().find(|(v, _)| v == *e).and_then(|(_, n)| n.clone());
if let Some(s) = event_value(e, "introduced") {
intro = norm_version.or(Some(s));
continue;
}
if let Some(fix_raw) = event_value(e, "fixed") {
let fix = norm_version.unwrap_or(fix_raw);
if interval_matches(
intro.as_deref(),
Some(&fix),
false,
effective_version,
target.as_ref(),
) {
matched = true;
break;
}
intro = None;
continue;
}
if let Some(la_raw) = event_value(e, "last_affected") {
let la = norm_version.unwrap_or(la_raw);
if interval_matches(
intro.as_deref(),
Some(&la),
true,
effective_version,
target.as_ref(),
) {
matched = true;
break;
}
intro = None;
continue;
}
}
if matched {
return true;
}
if let Some(i) = intro {
if interval_matches(Some(&i), None, false, effective_version, target.as_ref()) {
return true;
}
}
}
false
}
fn event_kinds(_: &serde_json::Value) -> [&'static str; 3] {
["introduced", "fixed", "last_affected"]
}
pub fn normalize_version_for_ecosystem(ecosystem: &str, version: &str) -> Option<String> {
match ecosystem.to_ascii_lowercase().as_str() {
"go" => normalize_go_version(version),
"pypi" => normalize_pypi_version(version),
_ => None,
}
}
fn normalize_go_version(version: &str) -> Option<String> {
let stripped = version.strip_prefix('v').or_else(|| version.strip_prefix('V'))?;
if stripped.is_empty() {
return None;
}
Some(stripped.to_string())
}
fn normalize_pypi_version(version: &str) -> Option<String> {
let trimmed = version.trim();
let lowered = if trimmed.chars().any(|c| c.is_ascii_uppercase()) {
std::borrow::Cow::Owned(trimmed.to_ascii_lowercase())
} else {
std::borrow::Cow::Borrowed(trimmed)
};
let v = lowered.as_ref();
let without_v = v.strip_prefix('v').unwrap_or(v);
let after_epoch = without_v.split_once('!').map(|(_, v)| v).unwrap_or(without_v);
let (public_part, local_part) = match after_epoch.split_once('+') {
Some((pub_, loc)) => (pub_, Some(loc)),
None => (after_epoch, None),
};
let mut remaining = public_part.to_string();
let mut pre_suffix: Option<String> = None;
let mut post_suffix: Option<String> = None;
let mut dev_suffix: Option<String> = None;
if let Some((base, n)) = split_off_marker(&remaining, ".dev") {
remaining = base;
dev_suffix = Some(format!("0.dev.{}", n));
}
if let Some((base, n)) = split_off_marker(&remaining, ".post") {
remaining = base;
post_suffix = Some(format!("post.{}", n));
}
for (marker, rank) in [("rc", 3u8), ("a", 1), ("b", 2)] {
if let Some((base, n)) = split_off_alpha_marker(&remaining, marker) {
remaining = base;
pre_suffix = Some(format!("{}.{}.{}", rank, marker, n));
break;
}
}
let (sv_pre, sv_build, final_remaining) = match (pre_suffix, post_suffix, dev_suffix) {
(Some(pre), None, None) => (Some(pre), None, remaining.clone()),
(None, Some(post), None) => {
let bumped = bump_patch(&remaining);
(Some(format!("0.{}", post)), None, bumped)
}
(None, None, Some(dev)) => (Some(dev), None, remaining.clone()),
(Some(pre), Some(post), None) => (Some(pre), Some(post), remaining.clone()),
(Some(pre), None, Some(dev)) => {
(Some(format!("{}.{}", pre, dev)), None, remaining.clone())
}
(None, Some(post), Some(dev)) => {
let bumped = bump_patch(&remaining);
(Some(format!("0.{}.{}", post, dev)), None, bumped)
}
(Some(pre), Some(post), Some(dev)) => {
(Some(format!("{}.{}", pre, dev)), Some(post), remaining.clone())
}
(None, None, None) => (None, None, remaining.clone()),
};
let final_build = match (sv_build, local_part) {
(Some(b), Some(l)) => Some(format!("{}.local.{}", b, sanitize_local(l))),
(Some(b), None) => Some(b),
(None, Some(l)) => Some(format!("local.{}", sanitize_local(l))),
(None, None) => None,
};
let mut out = final_remaining;
if let Some(pre) = sv_pre {
out.push('-');
out.push_str(&pre);
}
if let Some(build) = final_build {
out.push('+');
out.push_str(&build);
}
if out == version {
None
} else {
Some(out)
}
}
fn split_off_marker(s: &str, marker: &str) -> Option<(String, String)> {
let idx = find_pep440_marker(s, marker)?;
let base = &s[..idx];
let suffix = &s[idx + marker.len()..];
if suffix.chars().all(|c| c.is_ascii_digit()) && !suffix.is_empty() {
Some((base.to_string(), suffix.to_string()))
} else {
None
}
}
fn split_off_alpha_marker(s: &str, marker: &str) -> Option<(String, String)> {
let idx = find_pep440_marker(s, marker)?;
let before = s[..idx].chars().last();
if !before.is_some_and(|c| c.is_ascii_digit()) {
return None;
}
let base = &s[..idx];
let suffix = &s[idx + marker.len()..];
if suffix.chars().all(|c| c.is_ascii_digit()) && !suffix.is_empty() {
Some((base.to_string(), suffix.to_string()))
} else {
None
}
}
fn bump_patch(version: &str) -> String {
let parts: Vec<&str> = version.splitn(4, '.').collect();
let nums: Vec<Option<u64>> = parts.iter().take(3).map(|p| p.parse::<u64>().ok()).collect();
if nums.iter().any(|n| n.is_none()) {
return version.to_string();
}
let major = nums.first().and_then(|v| *v).unwrap_or(0);
let minor = nums.get(1).and_then(|v| *v).unwrap_or(0);
let patch = nums.get(2).and_then(|v| *v).unwrap_or(0);
format!("{}.{}.{}", major, minor, patch.saturating_add(1))
}
fn sanitize_local(local: &str) -> String {
local
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
c
} else {
'-'
}
})
.collect()
}
fn find_pep440_marker(s: &str, marker: &str) -> Option<usize> {
let dotted = marker.starts_with('.');
let mut search_from = 0;
while search_from < s.len() {
let rest = &s[search_from..];
let rel = rest.find(marker)?;
let abs = search_from + rel;
let before = s[..abs].chars().last();
let ok = if dotted {
true } else {
before.is_some_and(|c| c.is_ascii_digit())
};
let after = s.as_bytes().get(abs + marker.len()).copied();
let ok = ok && after.is_none_or(|b| b.is_ascii_digit());
if ok {
return Some(abs);
}
search_from = abs + marker.len();
}
None
}
fn event_value(event: &serde_json::Value, key: &str) -> Option<String> {
event.get(key).and_then(|v| v.as_str()).map(str::to_string)
}
fn sort_events(events: &[serde_json::Value]) -> Vec<&serde_json::Value> {
fn kind_rank(e: &serde_json::Value) -> u8 {
if e.get("introduced").is_some() {
0
} else if e.get("fixed").is_some() {
1
} else if e.get("last_affected").is_some() {
2
} else {
3
}
}
fn event_version(e: &serde_json::Value) -> Option<semver::Version> {
let raw = event_value(e, "introduced")
.or_else(|| event_value(e, "fixed"))
.or_else(|| event_value(e, "last_affected"))?;
if raw == "0" {
return Some(semver::Version::new(0, 0, 0));
}
semver::Version::parse(&raw).ok()
}
let mut indexed: Vec<(usize, &serde_json::Value, Option<semver::Version>, u8)> = events
.iter()
.enumerate()
.map(|(i, e)| (i, e, event_version(e), kind_rank(e)))
.collect();
indexed.sort_by(|(ai, _, av, ak), (bi, _, bv, bk)| match (av, bv) {
(Some(a), Some(b)) => a.cmp(b).then_with(|| ak.cmp(bk)).then_with(|| ai.cmp(bi)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => ai.cmp(bi),
});
indexed.into_iter().map(|(_, e, _, _)| e).collect()
}
fn interval_matches(
introduced: Option<&str>,
upper: Option<&str>,
inclusive_upper: bool,
version: &str,
target: Option<&semver::Version>,
) -> bool {
let lower_unbounded = matches!(introduced, Some("0"));
let lower = if lower_unbounded {
None
} else {
introduced.and_then(|s| semver::Version::parse(s).ok())
};
let upper_ver = upper.and_then(|s| semver::Version::parse(s).ok());
let Some(target) = target else {
if lower_unbounded && upper.is_none() {
return true;
}
if let Some(i) = introduced {
if i.eq_ignore_ascii_case(version) {
return true;
}
}
if let Some(u) = upper {
if inclusive_upper && u.eq_ignore_ascii_case(version) {
return true;
}
}
return false;
};
if let Some(l) = &lower {
if target < l {
return false;
}
}
if let Some(u) = &upper_ver {
if inclusive_upper {
if target > u {
return false;
}
} else if target >= u {
return false;
}
} else if upper.is_some() && upper_ver.is_none() {
return false;
}
true
}
pub fn parse_modified_str(s: Option<&str>) -> DateTime<Utc> {
s.and_then(|raw| DateTime::parse_from_rfc3339(raw).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now)
}
#[async_trait]
pub trait RegistryStore: Send + Sync + 'static {
async fn health_check(&self) -> StoreResult<()>;
async fn create_api_token(
&self,
org_id: Uuid,
user_id: Option<Uuid>,
name: &str,
scopes: &[TokenScope],
expires_at: Option<DateTime<Utc>>,
) -> StoreResult<(String, ApiToken)>;
async fn find_api_token_by_id(&self, token_id: Uuid) -> StoreResult<Option<ApiToken>>;
async fn list_api_tokens_by_org(&self, org_id: Uuid) -> StoreResult<Vec<ApiToken>>;
async fn find_api_token_by_prefix(
&self,
org_id: Uuid,
prefix: &str,
) -> StoreResult<Option<ApiToken>>;
async fn verify_api_token(&self, token: &str) -> StoreResult<Option<ApiToken>>;
async fn delete_api_token(&self, token_id: Uuid) -> StoreResult<()>;
async fn rotate_api_token(
&self,
token_id: Uuid,
new_name: Option<&str>,
delete_old: bool,
) -> StoreResult<(String, ApiToken, Option<ApiToken>)>;
async fn find_api_tokens_needing_rotation(
&self,
org_id: Option<Uuid>,
days_old: i64,
) -> StoreResult<Vec<ApiToken>>;
async fn get_org_setting(&self, org_id: Uuid, key: &str) -> StoreResult<Option<OrgSetting>>;
async fn set_org_setting(
&self,
org_id: Uuid,
key: &str,
value: serde_json::Value,
) -> StoreResult<OrgSetting>;
async fn delete_org_setting(&self, org_id: Uuid, key: &str) -> StoreResult<()>;
async fn create_organization(
&self,
name: &str,
slug: &str,
owner_id: Uuid,
plan: Plan,
) -> StoreResult<Organization>;
async fn find_organization_by_id(&self, org_id: Uuid) -> StoreResult<Option<Organization>>;
async fn find_organization_by_slug(&self, slug: &str) -> StoreResult<Option<Organization>>;
async fn list_organizations_by_user(&self, user_id: Uuid) -> StoreResult<Vec<Organization>>;
async fn update_organization_name(&self, org_id: Uuid, name: &str) -> StoreResult<()>;
async fn update_organization_slug(&self, org_id: Uuid, slug: &str) -> StoreResult<()>;
async fn update_organization_plan(&self, org_id: Uuid, plan: Plan) -> StoreResult<()>;
async fn organization_has_active_subscription(&self, org_id: Uuid) -> StoreResult<bool>;
async fn delete_organization(&self, org_id: Uuid) -> StoreResult<()>;
async fn create_org_member(
&self,
org_id: Uuid,
user_id: Uuid,
role: OrgRole,
) -> StoreResult<OrgMember>;
async fn find_org_member(&self, org_id: Uuid, user_id: Uuid) -> StoreResult<Option<OrgMember>>;
async fn list_org_members(&self, org_id: Uuid) -> StoreResult<Vec<OrgMember>>;
async fn update_org_member_role(
&self,
org_id: Uuid,
user_id: Uuid,
role: OrgRole,
) -> StoreResult<()>;
async fn delete_org_member(&self, org_id: Uuid, user_id: Uuid) -> StoreResult<()>;
#[allow(clippy::too_many_arguments)]
async fn record_audit_event(
&self,
org_id: Uuid,
user_id: Option<Uuid>,
event_type: AuditEventType,
description: String,
metadata: Option<serde_json::Value>,
ip_address: Option<&str>,
user_agent: Option<&str>,
);
async fn list_audit_logs(
&self,
org_id: Uuid,
limit: Option<i64>,
offset: Option<i64>,
event_types: &[AuditEventType],
) -> StoreResult<Vec<AuditLog>>;
async fn count_audit_logs(
&self,
org_id: Uuid,
event_types: &[AuditEventType],
) -> StoreResult<i64>;
async fn record_feature_usage(
&self,
org_id: Uuid,
user_id: Option<Uuid>,
feature: FeatureType,
metadata: Option<serde_json::Value>,
);
async fn count_feature_usage_by_org(
&self,
org_id: Uuid,
feature: FeatureType,
days: i64,
) -> StoreResult<i64>;
async fn create_user(
&self,
username: &str,
email: &str,
password_hash: &str,
) -> StoreResult<User>;
async fn find_user_by_id(&self, user_id: Uuid) -> StoreResult<Option<User>>;
async fn find_user_by_email(&self, email: &str) -> StoreResult<Option<User>>;
async fn find_user_by_username(&self, username: &str) -> StoreResult<Option<User>>;
async fn find_users_by_ids(&self, ids: &[Uuid]) -> StoreResult<Vec<User>>;
async fn set_user_api_token(&self, user_id: Uuid, token: &str) -> StoreResult<()>;
async fn enable_user_2fa(
&self,
user_id: Uuid,
secret: &str,
backup_codes: &[String],
) -> StoreResult<()>;
async fn disable_user_2fa(&self, user_id: Uuid) -> StoreResult<()>;
async fn update_user_2fa_verified(&self, user_id: Uuid) -> StoreResult<()>;
async fn remove_user_backup_code(&self, user_id: Uuid, code_index: usize) -> StoreResult<()>;
async fn find_user_by_github_id(&self, github_id: &str) -> StoreResult<Option<User>>;
async fn find_user_by_google_id(&self, google_id: &str) -> StoreResult<Option<User>>;
async fn link_user_github_account(
&self,
user_id: Uuid,
github_id: &str,
avatar_url: Option<&str>,
) -> StoreResult<()>;
async fn link_user_google_account(
&self,
user_id: Uuid,
google_id: &str,
avatar_url: Option<&str>,
) -> StoreResult<()>;
#[allow(clippy::too_many_arguments)]
async fn create_oauth_user(
&self,
username: &str,
email: &str,
password_hash: &str,
auth_provider: &str,
github_id: Option<&str>,
google_id: Option<&str>,
avatar_url: Option<&str>,
) -> StoreResult<User>;
async fn get_or_create_personal_org(
&self,
user_id: Uuid,
username: &str,
) -> StoreResult<Organization>;
async fn update_user_password_hash(
&self,
user_id: Uuid,
password_hash: &str,
) -> StoreResult<()>;
async fn mark_user_verified(&self, user_id: Uuid) -> StoreResult<()>;
async fn update_user_profile(
&self,
user_id: Uuid,
username: Option<&str>,
email: Option<&str>,
) -> StoreResult<User>;
async fn update_user_notification_prefs(
&self,
user_id: Uuid,
email_notifications: bool,
security_alerts: bool,
) -> StoreResult<()>;
async fn update_user_preferences(
&self,
user_id: Uuid,
preferences: &serde_json::Value,
) -> StoreResult<()>;
async fn create_verification_token(&self, user_id: Uuid) -> StoreResult<VerificationToken>;
async fn set_verification_token_expiry_hours(
&self,
token_id: Uuid,
hours: i64,
) -> StoreResult<()>;
async fn find_verification_token_by_token(
&self,
token: &str,
) -> StoreResult<Option<VerificationToken>>;
async fn mark_verification_token_used(&self, token_id: Uuid) -> StoreResult<()>;
#[allow(clippy::too_many_arguments)]
async fn record_suspicious_activity(
&self,
org_id: Option<Uuid>,
user_id: Option<Uuid>,
activity_type: SuspiciousActivityType,
severity: &str,
description: String,
metadata: Option<serde_json::Value>,
ip_address: Option<&str>,
user_agent: Option<&str>,
);
async fn create_federation(
&self,
org_id: Uuid,
created_by: Uuid,
name: &str,
description: &str,
services: &serde_json::Value,
) -> StoreResult<Federation>;
async fn find_federation_by_id(&self, id: Uuid) -> StoreResult<Option<Federation>>;
async fn list_federations_by_org(&self, org_id: Uuid) -> StoreResult<Vec<Federation>>;
async fn update_federation(
&self,
id: Uuid,
name: Option<&str>,
description: Option<&str>,
services: Option<&serde_json::Value>,
) -> StoreResult<Option<Federation>>;
async fn delete_federation(&self, id: Uuid) -> StoreResult<()>;
#[allow(clippy::too_many_arguments)]
async fn create_federation_scenario_activation(
&self,
federation_id: Uuid,
scenario_id: Option<Uuid>,
scenario_name: &str,
manifest_snapshot: &serde_json::Value,
service_overrides: &serde_json::Value,
per_service_state: &serde_json::Value,
activated_by: Uuid,
) -> StoreResult<FederationScenarioActivation>;
async fn find_active_federation_scenario_activation(
&self,
federation_id: Uuid,
) -> StoreResult<Option<FederationScenarioActivation>>;
async fn deactivate_federation_scenario_activation(
&self,
id: Uuid,
) -> StoreResult<Option<FederationScenarioActivation>>;
async fn update_federation_scenario_per_service_state(
&self,
id: Uuid,
per_service_state: &serde_json::Value,
) -> StoreResult<Option<FederationScenarioActivation>>;
async fn find_active_federation_scenarios_for_workspace(
&self,
workspace_id: Uuid,
) -> StoreResult<Vec<FederationScenarioActivation>>;
async fn list_unresolved_suspicious_activities(
&self,
org_id: Option<Uuid>,
user_id: Option<Uuid>,
severity: Option<&str>,
limit: Option<i64>,
) -> StoreResult<Vec<SuspiciousActivity>>;
async fn count_unresolved_suspicious_activities(&self, org_id: Uuid) -> StoreResult<i64>;
async fn resolve_suspicious_activity(
&self,
org_id: Uuid,
activity_id: Uuid,
resolved_by: Uuid,
) -> StoreResult<()>;
async fn create_cloud_workspace(
&self,
org_id: Uuid,
created_by: Uuid,
name: &str,
description: &str,
) -> StoreResult<CloudWorkspace>;
async fn find_cloud_workspace_by_id(&self, id: Uuid) -> StoreResult<Option<CloudWorkspace>>;
async fn list_cloud_workspaces_by_org(&self, org_id: Uuid) -> StoreResult<Vec<CloudWorkspace>>;
async fn update_cloud_workspace(
&self,
id: Uuid,
name: Option<&str>,
description: Option<&str>,
is_active: Option<bool>,
settings: Option<&serde_json::Value>,
) -> StoreResult<Option<CloudWorkspace>>;
async fn delete_cloud_workspace(&self, id: Uuid) -> StoreResult<()>;
#[allow(clippy::too_many_arguments)]
async fn create_cloud_service(
&self,
org_id: Uuid,
workspace_id: Option<Uuid>,
created_by: Uuid,
name: &str,
description: &str,
base_url: &str,
) -> StoreResult<CloudService>;
async fn find_cloud_service_by_id(&self, id: Uuid) -> StoreResult<Option<CloudService>>;
async fn list_cloud_services_by_org(&self, org_id: Uuid) -> StoreResult<Vec<CloudService>>;
async fn list_cloud_services_by_workspace(
&self,
org_id: Uuid,
workspace_id: Uuid,
) -> StoreResult<Vec<CloudService>>;
#[allow(clippy::too_many_arguments)]
async fn update_cloud_service(
&self,
id: Uuid,
name: Option<&str>,
description: Option<&str>,
base_url: Option<&str>,
enabled: Option<bool>,
tags: Option<&serde_json::Value>,
routes: Option<&serde_json::Value>,
workspace_id: Option<Option<Uuid>>,
) -> StoreResult<Option<CloudService>>;
async fn delete_cloud_service(&self, id: Uuid) -> StoreResult<()>;
#[allow(clippy::too_many_arguments)]
async fn create_cloud_fixture(
&self,
org_id: Uuid,
created_by: Uuid,
name: &str,
description: &str,
path: &str,
method: &str,
content: Option<&serde_json::Value>,
protocol: Option<&str>,
tags: Option<&serde_json::Value>,
workspace_id: Option<Uuid>,
route_path: Option<&str>,
) -> StoreResult<CloudFixture>;
async fn find_cloud_fixture_by_id(&self, id: Uuid) -> StoreResult<Option<CloudFixture>>;
async fn list_cloud_fixtures_by_org(
&self,
org_id: Uuid,
workspace_id: Option<Uuid>,
) -> StoreResult<Vec<CloudFixture>>;
#[allow(clippy::too_many_arguments)]
async fn update_cloud_fixture(
&self,
id: Uuid,
name: Option<&str>,
description: Option<&str>,
path: Option<&str>,
method: Option<&str>,
content: Option<&serde_json::Value>,
protocol: Option<&str>,
tags: Option<&serde_json::Value>,
route_path: Option<&str>,
workspace_id: Option<Option<Uuid>>,
) -> StoreResult<Option<CloudFixture>>;
async fn delete_cloud_fixture(&self, id: Uuid) -> StoreResult<()>;
async fn delete_cloud_fixtures_bulk(
&self,
org_id: Uuid,
ids: &[Uuid],
) -> StoreResult<Vec<Uuid>>;
#[allow(clippy::too_many_arguments)]
async fn create_hosted_mock(
&self,
org_id: Uuid,
project_id: Option<Uuid>,
name: &str,
slug: &str,
description: Option<&str>,
config_json: serde_json::Value,
openapi_spec_url: Option<&str>,
region: Option<&str>,
) -> StoreResult<HostedMock>;
async fn find_hosted_mock_by_id(&self, id: Uuid) -> StoreResult<Option<HostedMock>>;
async fn find_hosted_mock_by_slug(
&self,
org_id: Uuid,
slug: &str,
) -> StoreResult<Option<HostedMock>>;
async fn list_hosted_mocks_by_org(&self, org_id: Uuid) -> StoreResult<Vec<HostedMock>>;
async fn update_hosted_mock_status(
&self,
id: Uuid,
status: DeploymentStatus,
error_message: Option<&str>,
) -> StoreResult<()>;
async fn update_hosted_mock_urls(
&self,
id: Uuid,
deployment_url: Option<&str>,
internal_url: Option<&str>,
) -> StoreResult<()>;
async fn update_hosted_mock_health(
&self,
id: Uuid,
health_status: HealthStatus,
health_check_url: Option<&str>,
) -> StoreResult<()>;
async fn delete_hosted_mock(&self, id: Uuid) -> StoreResult<()>;
async fn subscribe_waitlist(
&self,
email: &str,
source: &str,
) -> StoreResult<WaitlistSubscriber>;
async fn unsubscribe_waitlist_by_token(&self, token: Uuid) -> StoreResult<bool>;
async fn get_or_create_current_usage_counter(&self, org_id: Uuid) -> StoreResult<UsageCounter>;
async fn list_usage_counters_by_org(&self, org_id: Uuid) -> StoreResult<Vec<UsageCounter>>;
async fn find_sso_config_by_org(&self, org_id: Uuid) -> StoreResult<Option<SSOConfiguration>>;
#[allow(clippy::too_many_arguments)]
async fn upsert_sso_config(
&self,
org_id: Uuid,
provider: SSOProvider,
saml_entity_id: Option<&str>,
saml_sso_url: Option<&str>,
saml_slo_url: Option<&str>,
saml_x509_cert: Option<&str>,
saml_name_id_format: Option<&str>,
attribute_mapping: Option<serde_json::Value>,
require_signed_assertions: bool,
require_signed_responses: bool,
allow_unsolicited_responses: bool,
) -> StoreResult<SSOConfiguration>;
async fn enable_sso_config(&self, org_id: Uuid) -> StoreResult<()>;
async fn disable_sso_config(&self, org_id: Uuid) -> StoreResult<()>;
async fn delete_sso_config(&self, org_id: Uuid) -> StoreResult<()>;
async fn is_saml_assertion_used(&self, assertion_id: &str, org_id: Uuid) -> StoreResult<bool>;
#[allow(clippy::too_many_arguments)]
async fn record_saml_assertion_used(
&self,
assertion_id: &str,
org_id: Uuid,
user_id: Option<Uuid>,
name_id: Option<&str>,
issued_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
) -> StoreResult<SAMLAssertionId>;
#[allow(clippy::too_many_arguments)]
async fn create_org_template(
&self,
org_id: Uuid,
name: &str,
description: Option<&str>,
blueprint_config: Option<serde_json::Value>,
security_baseline: Option<serde_json::Value>,
created_by: Uuid,
is_default: bool,
) -> StoreResult<OrgTemplate>;
async fn find_org_template_by_id(&self, id: Uuid) -> StoreResult<Option<OrgTemplate>>;
async fn list_org_templates_by_org(&self, org_id: Uuid) -> StoreResult<Vec<OrgTemplate>>;
async fn update_org_template(
&self,
template: &OrgTemplate,
name: Option<&str>,
description: Option<&str>,
blueprint_config: Option<serde_json::Value>,
security_baseline: Option<serde_json::Value>,
is_default: Option<bool>,
) -> StoreResult<OrgTemplate>;
async fn delete_org_template(&self, id: Uuid) -> StoreResult<()>;
#[allow(clippy::too_many_arguments)]
async fn create_template(
&self,
org_id: Option<Uuid>,
name: &str,
slug: &str,
description: &str,
author_id: Uuid,
version: &str,
category: TemplateCategory,
content_json: serde_json::Value,
) -> StoreResult<Template>;
async fn find_template_by_name_version(
&self,
name: &str,
version: &str,
) -> StoreResult<Option<Template>>;
async fn list_templates_by_org(&self, org_id: Uuid) -> StoreResult<Vec<Template>>;
async fn search_templates(
&self,
query: Option<&str>,
category: Option<&str>,
tags: &[String],
org_id: Option<Uuid>,
limit: i64,
offset: i64,
) -> StoreResult<Vec<Template>>;
async fn count_search_templates(
&self,
query: Option<&str>,
category: Option<&str>,
tags: &[String],
org_id: Option<Uuid>,
) -> StoreResult<i64>;
#[allow(clippy::too_many_arguments)]
async fn create_scenario(
&self,
org_id: Option<Uuid>,
name: &str,
slug: &str,
description: &str,
author_id: Uuid,
current_version: &str,
category: &str,
license: &str,
manifest_json: serde_json::Value,
) -> StoreResult<Scenario>;
async fn find_scenario_by_name(&self, name: &str) -> StoreResult<Option<Scenario>>;
async fn find_scenario_by_id(&self, id: Uuid) -> StoreResult<Option<Scenario>>;
async fn list_scenarios_by_org(&self, org_id: Uuid) -> StoreResult<Vec<Scenario>>;
#[allow(clippy::too_many_arguments)]
async fn search_scenarios(
&self,
query: Option<&str>,
category: Option<&str>,
tags: &[String],
org_id: Option<Uuid>,
sort: &str,
limit: i64,
offset: i64,
) -> StoreResult<Vec<Scenario>>;
async fn count_search_scenarios(
&self,
query: Option<&str>,
category: Option<&str>,
tags: &[String],
org_id: Option<Uuid>,
) -> StoreResult<i64>;
#[allow(clippy::too_many_arguments)]
async fn search_plugins(
&self,
query: Option<&str>,
category: Option<&str>,
language: Option<&str>,
tags: &[String],
sort_by: &str,
limit: i64,
offset: i64,
) -> StoreResult<Vec<Plugin>>;
async fn count_search_plugins(
&self,
query: Option<&str>,
category: Option<&str>,
language: Option<&str>,
tags: &[String],
) -> StoreResult<i64>;
async fn find_plugin_by_name(&self, name: &str) -> StoreResult<Option<Plugin>>;
async fn get_plugin_tags(&self, plugin_id: Uuid) -> StoreResult<Vec<String>>;
#[allow(clippy::too_many_arguments)]
async fn create_plugin(
&self,
name: &str,
description: &str,
version: &str,
category: &str,
license: &str,
repository: Option<&str>,
homepage: Option<&str>,
author_id: Uuid,
language: &str,
) -> StoreResult<Plugin>;
async fn list_plugin_versions(&self, plugin_id: Uuid) -> StoreResult<Vec<PluginVersion>>;
async fn find_plugin_version(
&self,
plugin_id: Uuid,
version: &str,
) -> StoreResult<Option<PluginVersion>>;
#[allow(clippy::too_many_arguments)]
async fn create_plugin_version(
&self,
plugin_id: Uuid,
version: &str,
download_url: &str,
checksum: &str,
file_size: i64,
min_mockforge_version: Option<&str>,
sbom_json: Option<&serde_json::Value>,
) -> StoreResult<PluginVersion>;
async fn get_plugin_version_sbom(
&self,
plugin_version_id: Uuid,
) -> StoreResult<Option<serde_json::Value>>;
async fn yank_plugin_version(&self, version_id: Uuid) -> StoreResult<()>;
async fn get_plugin_version_dependencies(
&self,
version_id: Uuid,
) -> StoreResult<std::collections::HashMap<String, String>>;
async fn add_plugin_version_dependency(
&self,
version_id: Uuid,
plugin_name: &str,
version_req: &str,
) -> StoreResult<()>;
async fn upsert_plugin_security_scan(
&self,
plugin_version_id: Uuid,
status: &str,
score: i16,
findings: &serde_json::Value,
scanner_version: Option<&str>,
) -> StoreResult<()>;
async fn latest_security_scan_for_plugin(
&self,
plugin_id: Uuid,
) -> StoreResult<Option<PluginSecurityScan>>;
async fn list_pending_security_scans(&self, limit: i64) -> StoreResult<Vec<PendingScanJob>>;
async fn find_osv_matches(
&self,
ecosystem: &str,
package_name: &str,
version: &str,
) -> StoreResult<Vec<OsvMatch>>;
async fn upsert_osv_advisory(&self, record: &OsvImportRecord) -> StoreResult<usize>;
async fn count_osv_advisories(&self) -> StoreResult<i64>;
async fn list_user_public_keys(&self, user_id: Uuid) -> StoreResult<Vec<UserPublicKey>>;
async fn list_user_public_keys_with_usage(
&self,
user_id: Uuid,
include_revoked: bool,
) -> StoreResult<Vec<UserPublicKeyWithUsage>>;
async fn create_user_public_key(
&self,
user_id: Uuid,
algorithm: &str,
public_key_b64: &str,
label: &str,
org_id: Option<Uuid>,
) -> StoreResult<UserPublicKey>;
async fn revoke_user_public_key(&self, user_id: Uuid, key_id: Uuid) -> StoreResult<bool>;
async fn revoke_org_public_key(&self, org_id: Uuid, key_id: Uuid) -> StoreResult<bool>;
async fn find_user_public_key_by_id(&self, key_id: Uuid) -> StoreResult<Option<UserPublicKey>>;
async fn list_org_public_keys_with_usage(
&self,
org_id: Uuid,
include_revoked: bool,
) -> StoreResult<Vec<UserPublicKeyWithUsage>>;
async fn list_keys_for_publisher(&self, author_id: Uuid) -> StoreResult<Vec<UserPublicKey>>;
async fn rotate_user_public_key(
&self,
user_id: Uuid,
old_key_id: Uuid,
algorithm: &str,
new_public_key_b64: &str,
new_label: &str,
) -> StoreResult<UserPublicKey>;
async fn record_plugin_version_attestation(
&self,
plugin_version_id: Uuid,
key_id: Option<Uuid>,
) -> StoreResult<()>;
async fn get_plugin_version_attestation(
&self,
plugin_version_id: Uuid,
) -> StoreResult<Option<(Uuid, DateTime<Utc>)>>;
async fn get_plugin_reviews(
&self,
plugin_id: Uuid,
limit: i64,
offset: i64,
) -> StoreResult<Vec<Review>>;
async fn count_plugin_reviews(&self, plugin_id: Uuid) -> StoreResult<i64>;
async fn create_plugin_review(
&self,
plugin_id: Uuid,
user_id: Uuid,
version: &str,
rating: i16,
title: Option<&str>,
comment: &str,
) -> StoreResult<Review>;
async fn get_plugin_review_stats(&self, plugin_id: Uuid) -> StoreResult<(f64, i64)>;
async fn get_plugin_review_distribution(
&self,
plugin_id: Uuid,
) -> StoreResult<std::collections::HashMap<i16, i64>>;
async fn find_existing_plugin_review(
&self,
plugin_id: Uuid,
user_id: Uuid,
) -> StoreResult<Option<Uuid>>;
async fn update_plugin_rating_stats(
&self,
plugin_id: Uuid,
avg: f64,
count: i32,
) -> StoreResult<()>;
async fn increment_plugin_review_vote(
&self,
plugin_id: Uuid,
review_id: Uuid,
helpful: bool,
) -> StoreResult<()>;
async fn increment_plugin_download(
&self,
plugin_id: Uuid,
plugin_version_id: Uuid,
) -> StoreResult<()>;
async fn take_down_plugin(&self, plugin_id: Uuid, reason: Option<&str>) -> StoreResult<()>;
async fn restore_plugin(&self, plugin_id: Uuid) -> StoreResult<()>;
async fn list_taken_down_plugins(&self) -> StoreResult<Vec<Plugin>>;
async fn find_review_in_plugin(
&self,
plugin_id: Uuid,
review_id: Uuid,
) -> StoreResult<Option<Review>>;
async fn set_review_author_response(
&self,
review_id: Uuid,
text: Option<&str>,
) -> StoreResult<()>;
async fn get_user_public_info(&self, user_id: Uuid) -> StoreResult<Option<(String, String)>>;
async fn get_template_reviews(
&self,
template_id: Uuid,
limit: i64,
offset: i64,
) -> StoreResult<Vec<TemplateReview>>;
async fn count_template_reviews(&self, template_id: Uuid) -> StoreResult<i64>;
async fn create_template_review(
&self,
template_id: Uuid,
reviewer_id: Uuid,
rating: i32,
title: Option<&str>,
comment: &str,
) -> StoreResult<TemplateReview>;
async fn update_template_review_stats(&self, template_id: Uuid) -> StoreResult<()>;
async fn find_existing_template_review(
&self,
template_id: Uuid,
reviewer_id: Uuid,
) -> StoreResult<Option<Uuid>>;
async fn toggle_template_star(
&self,
template_id: Uuid,
user_id: Uuid,
) -> StoreResult<(bool, i64)>;
async fn is_template_starred_by(&self, template_id: Uuid, user_id: Uuid) -> StoreResult<bool>;
async fn count_template_stars(&self, template_id: Uuid) -> StoreResult<i64>;
async fn count_template_stars_batch(
&self,
template_ids: &[Uuid],
) -> StoreResult<std::collections::HashMap<Uuid, i64>>;
async fn get_scenario_reviews(
&self,
scenario_id: Uuid,
limit: i64,
offset: i64,
) -> StoreResult<Vec<ScenarioReview>>;
async fn count_scenario_reviews(&self, scenario_id: Uuid) -> StoreResult<i64>;
async fn create_scenario_review(
&self,
scenario_id: Uuid,
reviewer_id: Uuid,
rating: i32,
title: Option<&str>,
comment: &str,
) -> StoreResult<ScenarioReview>;
async fn update_scenario_review_stats(&self, scenario_id: Uuid) -> StoreResult<()>;
async fn find_existing_scenario_review(
&self,
scenario_id: Uuid,
reviewer_id: Uuid,
) -> StoreResult<Option<Uuid>>;
async fn increment_scenario_review_helpful_count(
&self,
scenario_id: Uuid,
review_id: Uuid,
) -> StoreResult<()>;
async fn toggle_scenario_star(
&self,
scenario_id: Uuid,
user_id: Uuid,
) -> StoreResult<(bool, i64)>;
async fn is_scenario_starred_by(&self, scenario_id: Uuid, user_id: Uuid) -> StoreResult<bool>;
async fn count_scenario_stars(&self, scenario_id: Uuid) -> StoreResult<i64>;
async fn count_scenario_stars_batch(
&self,
scenario_ids: &[Uuid],
) -> StoreResult<std::collections::HashMap<Uuid, i64>>;
async fn yank_scenario_version(&self, version_id: Uuid) -> StoreResult<()>;
async fn get_admin_analytics_snapshot(&self) -> StoreResult<AdminAnalyticsSnapshot>;
async fn get_conversion_funnel_snapshot(
&self,
interval: &str,
) -> StoreResult<ConversionFunnelSnapshot>;
async fn list_user_settings_raw(&self, user_id: Uuid) -> StoreResult<Vec<UserSettingRow>>;
async fn list_user_api_tokens(&self, user_id: Uuid) -> StoreResult<Vec<ApiToken>>;
async fn get_org_membership_role(
&self,
org_id: Uuid,
user_id: Uuid,
) -> StoreResult<Option<String>>;
async fn list_org_settings_raw(&self, org_id: Uuid) -> StoreResult<Vec<OrgSettingRow>>;
async fn list_org_projects_raw(&self, org_id: Uuid) -> StoreResult<Vec<ProjectRow>>;
async fn list_org_subscriptions_raw(&self, org_id: Uuid) -> StoreResult<Vec<SubscriptionRow>>;
async fn list_org_hosted_mocks_raw(&self, org_id: Uuid) -> StoreResult<Vec<HostedMock>>;
async fn delete_user_data_cascade(&self, user_id: Uuid) -> StoreResult<usize>;
}
#[derive(Debug, Clone)]
pub struct AdminAnalyticsSnapshot {
pub total_users: i64,
pub verified_users: i64,
pub auth_providers: Vec<(Option<String>, i64)>,
pub new_users_7d: i64,
pub new_users_30d: i64,
pub total_orgs: i64,
pub plan_distribution: Vec<(String, i64)>,
pub active_subs: i64,
pub trial_orgs: i64,
pub total_requests: Option<i64>,
pub total_storage: Option<i64>,
pub total_ai_tokens: Option<i64>,
pub top_orgs: Vec<(Uuid, String, String, i64, i64)>,
pub hosted_mocks_count: i64,
pub hosted_mocks_orgs: i64,
pub hosted_mocks_30d: i64,
pub plugins_count: i64,
pub plugins_orgs: i64,
pub plugins_30d: i64,
pub templates_count: i64,
pub templates_orgs: i64,
pub templates_30d: i64,
pub scenarios_count: i64,
pub scenarios_orgs: i64,
pub scenarios_30d: i64,
pub api_tokens_count: i64,
pub api_tokens_orgs: i64,
pub api_tokens_30d: i64,
pub user_growth_30d: Vec<(chrono::NaiveDate, i64)>,
pub org_growth_30d: Vec<(chrono::NaiveDate, i64)>,
pub logins_24h: i64,
pub logins_7d: i64,
pub api_requests_24h: i64,
pub api_requests_7d: i64,
}
#[derive(Debug, Clone)]
pub struct ConversionFunnelSnapshot {
pub signups: i64,
pub verified: i64,
pub logged_in: i64,
pub org_created: i64,
pub feature_users: i64,
pub checkout_initiated: i64,
pub paid_subscribers: i64,
pub time_to_convert_days: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct UserSettingRow {
pub key: String,
pub value: serde_json::Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct OrgSettingRow {
pub key: String,
pub value: serde_json::Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct ProjectRow {
pub id: Uuid,
pub name: String,
pub visibility: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct SubscriptionRow {
pub id: Uuid,
pub plan: String,
pub status: String,
pub current_period_end: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
#[cfg(test)]
mod osv_matcher_tests {
use super::{normalize_version_for_ecosystem, version_affected, version_affected_in_ecosystem};
use serde_json::json;
#[test]
fn literal_versions_list_matches() {
let affected = json!({
"versions": ["1.2.3", "1.2.4"],
"ranges": []
});
assert!(version_affected(&affected, "1.2.3"));
assert!(version_affected(&affected, "1.2.4"));
assert!(!version_affected(&affected, "1.2.5"));
}
#[test]
fn introduced_zero_all_versions() {
let affected = json!({
"versions": [],
"ranges": [{
"type": "SEMVER",
"events": [{"introduced": "0"}]
}]
});
assert!(version_affected(&affected, "0.0.1"));
assert!(version_affected(&affected, "999.0.0"));
}
#[test]
fn introduced_fixed_exclusive_upper() {
let affected = json!({
"versions": [],
"ranges": [{
"type": "SEMVER",
"events": [
{"introduced": "1.2.0"},
{"fixed": "1.5.0"}
]
}]
});
assert!(!version_affected(&affected, "1.1.9"));
assert!(version_affected(&affected, "1.2.0"));
assert!(version_affected(&affected, "1.3.0"));
assert!(version_affected(&affected, "1.4.99"));
assert!(!version_affected(&affected, "1.5.0"), "fixed is exclusive");
assert!(!version_affected(&affected, "2.0.0"));
}
#[test]
fn last_affected_inclusive_upper() {
let affected = json!({
"ranges": [{
"type": "SEMVER",
"events": [
{"introduced": "1.0.0"},
{"last_affected": "1.2.3"}
]
}]
});
assert!(version_affected(&affected, "1.0.0"));
assert!(version_affected(&affected, "1.2.3"), "last_affected is inclusive");
assert!(!version_affected(&affected, "1.2.4"));
}
#[test]
fn multiple_intervals_per_range() {
let affected = json!({
"ranges": [{
"type": "SEMVER",
"events": [
{"introduced": "1.0.0"},
{"fixed": "1.1.0"},
{"introduced": "2.0.0"},
{"fixed": "2.1.0"}
]
}]
});
assert!(version_affected(&affected, "1.0.5"));
assert!(!version_affected(&affected, "1.5.0"), "between fixes");
assert!(version_affected(&affected, "2.0.0"));
assert!(!version_affected(&affected, "2.1.0"));
}
#[test]
fn git_ranges_ignored() {
let affected = json!({
"ranges": [{
"type": "GIT",
"events": [{"introduced": "0"}]
}]
});
assert!(!version_affected(&affected, "1.2.3"));
}
#[test]
fn go_ecosystem_strips_v_prefix() {
let affected = serde_json::json!({
"ranges": [{
"type": "SEMVER",
"events": [{"introduced": "v1.2.0"}, {"fixed": "v1.5.0"}]
}]
});
assert!(!version_affected(&affected, "1.3.0"));
assert!(version_affected_in_ecosystem(&affected, "1.3.0", "Go"));
assert!(version_affected_in_ecosystem(&affected, "v1.3.0", "Go"));
assert!(!version_affected_in_ecosystem(&affected, "1.5.0", "Go"), "fixed exclusive");
assert!(!version_affected_in_ecosystem(&affected, "1.1.9", "Go"));
}
#[test]
fn pypi_prerelease_tags_normalize() {
assert_eq!(
normalize_version_for_ecosystem("PyPI", "1.0.0a1").as_deref(),
Some("1.0.0-1.a.1")
);
assert_eq!(
normalize_version_for_ecosystem("PyPI", "1.2.3rc2").as_deref(),
Some("1.2.3-3.rc.2")
);
assert_eq!(
normalize_version_for_ecosystem("PyPI", "2.0.0.dev7").as_deref(),
Some("2.0.0-0.dev.7")
);
assert_eq!(
normalize_version_for_ecosystem("PyPI", "3.1.4.post1").as_deref(),
Some("3.1.5-0.post.1")
);
}
#[test]
fn pypi_epoch_gets_stripped() {
assert_eq!(normalize_version_for_ecosystem("PyPI", "2!1.0.0").as_deref(), Some("1.0.0"),);
}
#[test]
fn pypi_conformance_table() {
let cases: &[(&str, Option<&str>)] = &[
("1.2.3", None),
("0.0.1", None),
("1!2.3.4", Some("2.3.4")),
("10!0.1.0", Some("0.1.0")),
("v1.2.3", Some("1.2.3")),
("V1.2.3", Some("1.2.3")),
("1.0.0a1", Some("1.0.0-1.a.1")),
("1.0.0b2", Some("1.0.0-2.b.2")),
("1.2.3rc4", Some("1.2.3-3.rc.4")),
("1.0.0RC1", Some("1.0.0-3.rc.1")),
("1.0.0A1", Some("1.0.0-1.a.1")),
("2.0.0.dev7", Some("2.0.0-0.dev.7")),
("3.1.4.post1", Some("3.1.5-0.post.1")),
("1.0.0+ubuntu1", Some("1.0.0+local.ubuntu1")),
("1.0.0+deb.9", Some("1.0.0+local.deb.9")),
("1.0.0+has_underscore", Some("1.0.0+local.has-underscore")),
("1.0.0a1+ubuntu", Some("1.0.0-1.a.1+local.ubuntu")),
("1.0.0rc1.post2", Some("1.0.0-3.rc.1+post.2")),
("1.0.0b2.dev3", Some("1.0.0-2.b.2.0.dev.3")),
("1.0.0.post1.dev2", Some("1.0.1-0.post.1.0.dev.2")),
("2!1.0.0a1+ubuntu", Some("1.0.0-1.a.1+local.ubuntu")),
("1.0.0-alpha", None),
("1.2.3-beta", None),
];
for (input, expected) in cases {
let got = normalize_version_for_ecosystem("PyPI", input);
assert_eq!(
got.as_deref(),
*expected,
"normalize_version_for_ecosystem(\"PyPI\", {:?}) mismatch — got {:?}, expected {:?}",
input,
got,
expected,
);
}
}
#[test]
fn pypi_normalizer_agrees_with_pep440_rs_on_ordering() {
use pep440_rs::Version as PyVer;
use std::cmp::Ordering;
let pairs: &[(&str, &str)] = &[
("1.0.0", "1.0.1"),
("1.0.0", "1.1.0"),
("0.9.9", "1.0.0"),
("1.0.0a1", "1.0.0"),
("1.0.0b2", "1.0.0rc1"),
("1.0.0rc1", "1.0.0"),
("1.0.0.dev1", "1.0.0a1"),
("1.0.0a1", "1.0.0b1"),
("1.0.0b1", "1.0.0rc1"),
("1.0.0.dev1", "1.0.0"),
("1.0.0", "1.0.0.post1"),
("1.0.0.post1", "1.0.0.post2"),
("1.0.0.post1", "1.0.1"),
("v1.0.0", "1.0.1"),
];
let public_release_pairs: &[(&str, &str)] =
&[("1.0.0", "1.0.0+ubuntu"), ("1.0.0+deb", "1.0.0+ubuntu")];
for (a_raw, b_raw) in public_release_pairs {
let a = a_raw.split('+').next().unwrap();
let b = b_raw.split('+').next().unwrap();
let py_a: PyVer = a.parse().unwrap();
let py_b: PyVer = b.parse().unwrap();
assert_eq!(
py_a.cmp(&py_b),
Ordering::Equal,
"public releases of {:?} / {:?} should compare equal",
a_raw,
b_raw,
);
}
for (a_raw, b_raw) in pairs {
let py_a: PyVer =
a_raw.parse().unwrap_or_else(|e| panic!("pep440_rs reject {}: {}", a_raw, e));
let py_b: PyVer =
b_raw.parse().unwrap_or_else(|e| panic!("pep440_rs reject {}: {}", b_raw, e));
let oracle = py_a.cmp(&py_b);
let norm_a =
normalize_version_for_ecosystem("PyPI", a_raw).unwrap_or_else(|| a_raw.to_string());
let norm_b =
normalize_version_for_ecosystem("PyPI", b_raw).unwrap_or_else(|| b_raw.to_string());
let strip_meta = |s: &str| s.split('+').next().unwrap_or(s).to_string();
let (sa, sb) = (strip_meta(&norm_a), strip_meta(&norm_b));
let ours_a = semver::Version::parse(&sa)
.unwrap_or_else(|e| panic!("{} → {} not semver: {}", a_raw, sa, e));
let ours_b = semver::Version::parse(&sb)
.unwrap_or_else(|e| panic!("{} → {} not semver: {}", b_raw, sb, e));
let ours = ours_a.cmp(&ours_b);
if oracle == Ordering::Equal {
assert_eq!(
ours,
Ordering::Equal,
"oracle equal but ours not equal for ({:?}, {:?})",
a_raw,
b_raw,
);
} else {
assert_eq!(
ours, oracle,
"ordering mismatch for ({:?}, {:?}): pep440_rs {:?} ours {:?} via ({:?}, {:?})",
a_raw, b_raw, oracle, ours, sa, sb,
);
}
}
}
#[test]
fn pypi_local_identifiers_are_semver_parsable() {
let inputs = &[
"1.0.0+ubuntu1",
"1.0.0+deb.9",
"1.0.0+has_underscore",
"1.0.0a1+ubuntu",
"2!1.0.0a1+ubuntu",
"1.0.0rc1.post2",
"1.0.0b2.dev3",
"1.0.0.post1.dev2",
];
for input in inputs {
let normalized = normalize_version_for_ecosystem("PyPI", input)
.unwrap_or_else(|| panic!("{} did not normalize", input));
semver::Version::parse(&normalized).unwrap_or_else(|e| {
panic!("normalized form of {} ({}) failed semver parse: {}", input, normalized, e)
});
}
}
#[test]
fn pypi_interval_match_with_prerelease() {
let affected = serde_json::json!({
"ranges": [{
"type": "ECOSYSTEM",
"events": [{"introduced": "0"}, {"last_affected": "1.0.0"}]
}]
});
assert!(version_affected_in_ecosystem(&affected, "0.9.0", "PyPI"));
assert!(version_affected_in_ecosystem(&affected, "1.0.0a1", "PyPI"));
assert!(version_affected_in_ecosystem(&affected, "1.0.0", "PyPI"));
assert!(!version_affected_in_ecosystem(&affected, "1.0.1", "PyPI"));
}
#[test]
fn unknown_ecosystem_falls_through_unchanged() {
assert_eq!(normalize_version_for_ecosystem("RubyGems", "1.2.3"), None);
assert_eq!(normalize_version_for_ecosystem("", "1.2.3"), None);
let affected = serde_json::json!({
"ranges": [{
"type": "SEMVER",
"events": [{"introduced": "1.0.0"}, {"fixed": "2.0.0"}]
}]
});
assert_eq!(
version_affected(&affected, "1.5.0"),
version_affected_in_ecosystem(&affected, "1.5.0", "RubyGems"),
);
}
#[test]
fn events_are_sorted_before_pairing() {
let affected = serde_json::json!({
"ranges": [{
"type": "SEMVER",
"events": [
{"fixed": "2.1.0"},
{"introduced": "1.0.0"},
{"fixed": "1.1.0"},
{"introduced": "2.0.0"}
]
}]
});
assert!(version_affected(&affected, "1.0.5"));
assert!(!version_affected(&affected, "1.5.0"), "sorting pairs (1.0,1.1) not (1.0,2.1)");
assert!(version_affected(&affected, "2.0.0"));
assert!(!version_affected(&affected, "2.1.0"));
}
#[test]
fn non_semver_target_conservative_fallback() {
let literal = json!({
"versions": ["2024-01-15"]
});
assert!(version_affected(&literal, "2024-01-15"));
let open = json!({
"ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}]}]
});
assert!(version_affected(&open, "2024-01-15"));
let bounded = json!({
"ranges": [{
"type": "SEMVER",
"events": [{"introduced": "1.0.0"}, {"fixed": "2.0.0"}]
}]
});
assert!(!version_affected(&bounded, "2024-01-15"));
}
}