use crate::types::SarifLog;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SchemaVersion {
V1_0_0,
V2_0_0,
V2_1_0,
}
impl fmt::Display for SchemaVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SchemaVersion::V1_0_0 => write!(f, "1.0.0"),
SchemaVersion::V2_0_0 => write!(f, "2.0.0"),
SchemaVersion::V2_1_0 => write!(f, "2.1.0"),
}
}
}
impl SchemaVersion {
pub fn from_string(version: &str) -> Result<Self, SchemaVersionError> {
match version {
"1.0.0" => Ok(SchemaVersion::V1_0_0),
"2.0.0" => Ok(SchemaVersion::V2_0_0),
"2.1.0" => Ok(SchemaVersion::V2_1_0),
_ => Err(SchemaVersionError::UnsupportedVersion(version.to_string())),
}
}
pub fn schema_uri(&self) -> String {
match self {
SchemaVersion::V1_0_0 => "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-1.0.0.json".to_string(),
SchemaVersion::V2_0_0 => "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.0.0.json".to_string(),
SchemaVersion::V2_1_0 => "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
}
}
pub fn is_compatible_with(&self, other: &SchemaVersion) -> bool {
match (self, other) {
(a, b) if a == b => true,
(SchemaVersion::V2_0_0, SchemaVersion::V2_1_0) => true,
(SchemaVersion::V2_1_0, SchemaVersion::V2_0_0) => true,
_ => false,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SchemaVersionError {
#[error("Unsupported SARIF version: {0}")]
UnsupportedVersion(String),
#[error("Migration failed: {0}")]
MigrationFailed(String),
#[error("Invalid schema format: {0}")]
InvalidSchema(String),
#[error("Version detection failed: {0}")]
VersionDetectionFailed(String),
}
#[derive(Debug, Clone)]
pub struct SchemaEvolutionConfig {
pub target_version: SchemaVersion,
pub preserve_unknown_fields: bool,
pub strict_validation: bool,
pub include_warnings: bool,
}
impl Default for SchemaEvolutionConfig {
fn default() -> Self {
Self {
target_version: SchemaVersion::V2_1_0,
preserve_unknown_fields: true,
strict_validation: false,
include_warnings: true,
}
}
}
#[derive(Debug, Clone)]
pub struct MigrationWarning {
pub warning_type: MigrationWarningType,
pub message: String,
pub path: String,
pub original_value: Option<Value>,
pub new_value: Option<Value>,
}
#[derive(Debug, Clone)]
pub enum MigrationWarningType {
FieldRenamed,
FieldRemoved,
FieldAdded,
ValueTransformed,
UnknownFieldPreserved,
StructureChanged,
}
#[derive(Debug)]
pub struct MigrationResult {
pub log: SarifLog,
pub warnings: Vec<MigrationWarning>,
pub source_version: SchemaVersion,
pub target_version: SchemaVersion,
}
pub struct SchemaEvolutionManager {
config: SchemaEvolutionConfig,
migrators: HashMap<(SchemaVersion, SchemaVersion), Box<dyn SchemaMigrator>>,
}
impl SchemaEvolutionManager {
pub fn new(config: SchemaEvolutionConfig) -> Self {
let mut manager = Self {
config,
migrators: HashMap::new(),
};
manager.register_migrators();
manager
}
pub fn default() -> Self {
Self::new(SchemaEvolutionConfig::default())
}
pub fn detect_version(&self, value: &Value) -> Result<SchemaVersion, SchemaVersionError> {
if let Some(obj) = value.as_object() {
if let Some(version_val) = obj.get("version")
&& let Some(version_str) = version_val.as_str()
{
return SchemaVersion::from_string(version_str);
}
if let Some(schema_val) = obj.get("$schema")
&& let Some(schema_str) = schema_val.as_str()
{
return self.detect_version_from_schema_uri(schema_str);
}
return self.detect_version_heuristic(obj);
}
Err(SchemaVersionError::VersionDetectionFailed(
"Unable to determine SARIF version".to_string(),
))
}
pub fn migrate(&self, value: Value) -> Result<MigrationResult, SchemaVersionError> {
let source_version = self.detect_version(&value)?;
let target_version = self.config.target_version.clone();
if source_version.is_compatible_with(&target_version) && source_version == target_version {
let log: SarifLog = serde_json::from_value(value)
.map_err(|e| SchemaVersionError::InvalidSchema(e.to_string()))?;
return Ok(MigrationResult {
log,
warnings: vec![],
source_version,
target_version,
});
}
let migration_path = self.find_migration_path(&source_version, &target_version)?;
let mut current_value = value;
let mut all_warnings = Vec::new();
for (from, to) in migration_path {
if let Some(migrator) = self.migrators.get(&(from.clone(), to.clone())) {
let result = migrator.migrate(current_value)?;
current_value = result.migrated_value;
all_warnings.extend(result.warnings);
} else {
return Err(SchemaVersionError::MigrationFailed(format!(
"No migrator found for {} -> {}",
from, to
)));
}
}
let log: SarifLog = serde_json::from_value(current_value)
.map_err(|e| SchemaVersionError::InvalidSchema(e.to_string()))?;
Ok(MigrationResult {
log,
warnings: all_warnings,
source_version,
target_version,
})
}
fn register_migrators(&mut self) {
self.migrators.insert(
(SchemaVersion::V1_0_0, SchemaVersion::V2_0_0),
Box::new(V1ToV2Migrator::new()),
);
self.migrators.insert(
(SchemaVersion::V2_0_0, SchemaVersion::V2_1_0),
Box::new(V2ToV2_1Migrator::new()),
);
self.migrators.insert(
(SchemaVersion::V1_0_0, SchemaVersion::V2_1_0),
Box::new(V1ToV2_1Migrator::new()),
);
}
fn find_migration_path(
&self,
from: &SchemaVersion,
to: &SchemaVersion,
) -> Result<Vec<(SchemaVersion, SchemaVersion)>, SchemaVersionError> {
if from == to {
return Ok(vec![]);
}
if self.migrators.contains_key(&(from.clone(), to.clone())) {
return Ok(vec![(from.clone(), to.clone())]);
}
match (from, to) {
(SchemaVersion::V1_0_0, SchemaVersion::V2_1_0) => Ok(vec![
(SchemaVersion::V1_0_0, SchemaVersion::V2_0_0),
(SchemaVersion::V2_0_0, SchemaVersion::V2_1_0),
]),
_ => Err(SchemaVersionError::MigrationFailed(format!(
"No migration path found from {} to {}",
from, to
))),
}
}
fn detect_version_from_schema_uri(
&self,
schema_uri: &str,
) -> Result<SchemaVersion, SchemaVersionError> {
if schema_uri.contains("1.0.0") {
Ok(SchemaVersion::V1_0_0)
} else if schema_uri.contains("2.0.0") {
Ok(SchemaVersion::V2_0_0)
} else if schema_uri.contains("2.1.0") {
Ok(SchemaVersion::V2_1_0)
} else {
Err(SchemaVersionError::VersionDetectionFailed(format!(
"Unknown schema URI: {}",
schema_uri
)))
}
}
fn detect_version_heuristic(
&self,
obj: &Map<String, Value>,
) -> Result<SchemaVersion, SchemaVersionError> {
if obj.contains_key("inlineExternalProperties") {
return Ok(SchemaVersion::V2_1_0);
}
if let Some(runs) = obj.get("runs")
&& let Some(runs_array) = runs.as_array()
&& let Some(first_run) = runs_array.first()
&& let Some(run_obj) = first_run.as_object()
{
if run_obj.contains_key("tool") {
return Ok(SchemaVersion::V2_0_0);
} else if run_obj.contains_key("toolInfo") {
return Ok(SchemaVersion::V1_0_0);
}
}
Ok(SchemaVersion::V2_1_0)
}
}
trait SchemaMigrator: Send + Sync {
fn migrate(&self, value: Value) -> Result<MigrationStepResult, SchemaVersionError>;
}
struct MigrationStepResult {
migrated_value: Value,
warnings: Vec<MigrationWarning>,
}
struct V1ToV2Migrator;
impl V1ToV2Migrator {
fn new() -> Self {
Self
}
}
impl SchemaMigrator for V1ToV2Migrator {
fn migrate(&self, mut value: Value) -> Result<MigrationStepResult, SchemaVersionError> {
let mut warnings = Vec::new();
if let Some(obj) = value.as_object_mut() {
obj.insert("version".to_string(), Value::String("2.0.0".to_string()));
obj.insert(
"$schema".to_string(),
Value::String(SchemaVersion::V2_0_0.schema_uri()),
);
if let Some(runs) = obj.get_mut("runs")
&& let Some(runs_array) = runs.as_array_mut()
{
for run in runs_array {
self.migrate_run_v1_to_v2(run, &mut warnings)?;
}
}
}
Ok(MigrationStepResult {
migrated_value: value,
warnings,
})
}
}
impl V1ToV2Migrator {
fn migrate_run_v1_to_v2(
&self,
run: &mut Value,
warnings: &mut Vec<MigrationWarning>,
) -> Result<(), SchemaVersionError> {
if let Some(run_obj) = run.as_object_mut() {
if let Some(tool_info) = run_obj.remove("toolInfo") {
run_obj.insert("tool".to_string(), tool_info);
warnings.push(MigrationWarning {
warning_type: MigrationWarningType::FieldRenamed,
message: "Renamed 'toolInfo' to 'tool'".to_string(),
path: "runs[].toolInfo".to_string(),
original_value: None,
new_value: None,
});
}
if let Some(results) = run_obj.get_mut("results")
&& let Some(results_array) = results.as_array_mut()
{
for result in results_array {
self.migrate_result_v1_to_v2(result, warnings)?;
}
}
}
Ok(())
}
fn migrate_result_v1_to_v2(
&self,
result: &mut Value,
warnings: &mut Vec<MigrationWarning>,
) -> Result<(), SchemaVersionError> {
if let Some(result_obj) = result.as_object_mut() {
if let Some(locations) = result_obj.get_mut("locations")
&& let Some(locations_array) = locations.as_array_mut()
{
for location in locations_array {
self.migrate_location_v1_to_v2(location, warnings)?;
}
}
}
Ok(())
}
fn migrate_location_v1_to_v2(
&self,
location: &mut Value,
warnings: &mut Vec<MigrationWarning>,
) -> Result<(), SchemaVersionError> {
if let Some(location_obj) = location.as_object_mut() {
if let Some(result_file) = location_obj.remove("resultFile") {
let mut physical_location = Map::new();
physical_location.insert("artifactLocation".to_string(), result_file);
if let Some(region) = location_obj.remove("region") {
physical_location.insert("region".to_string(), region);
}
location_obj.insert(
"physicalLocation".to_string(),
Value::Object(physical_location),
);
warnings.push(MigrationWarning {
warning_type: MigrationWarningType::StructureChanged,
message: "Restructured location format for SARIF 2.0".to_string(),
path: "runs[].results[].locations[]".to_string(),
original_value: None,
new_value: None,
});
}
}
Ok(())
}
}
struct V2ToV2_1Migrator;
impl V2ToV2_1Migrator {
fn new() -> Self {
Self
}
}
impl SchemaMigrator for V2ToV2_1Migrator {
fn migrate(&self, mut value: Value) -> Result<MigrationStepResult, SchemaVersionError> {
let mut warnings = Vec::new();
if let Some(obj) = value.as_object_mut() {
obj.insert("version".to_string(), Value::String("2.1.0".to_string()));
obj.insert(
"$schema".to_string(),
Value::String(SchemaVersion::V2_1_0.schema_uri()),
);
warnings.push(MigrationWarning {
warning_type: MigrationWarningType::ValueTransformed,
message: "Updated to SARIF 2.1.0 - new features available".to_string(),
path: "version".to_string(),
original_value: Some(Value::String("2.0.0".to_string())),
new_value: Some(Value::String("2.1.0".to_string())),
});
}
Ok(MigrationStepResult {
migrated_value: value,
warnings,
})
}
}
struct V1ToV2_1Migrator {
v1_to_v2: V1ToV2Migrator,
v2_to_v2_1: V2ToV2_1Migrator,
}
impl V1ToV2_1Migrator {
fn new() -> Self {
Self {
v1_to_v2: V1ToV2Migrator::new(),
v2_to_v2_1: V2ToV2_1Migrator::new(),
}
}
}
impl SchemaMigrator for V1ToV2_1Migrator {
fn migrate(&self, value: Value) -> Result<MigrationStepResult, SchemaVersionError> {
let v2_result = self.v1_to_v2.migrate(value)?;
let v2_1_result = self.v2_to_v2_1.migrate(v2_result.migrated_value)?;
let mut all_warnings = v2_result.warnings;
all_warnings.extend(v2_1_result.warnings);
Ok(MigrationStepResult {
migrated_value: v2_1_result.migrated_value,
warnings: all_warnings,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_version_detection() {
let manager = SchemaEvolutionManager::default();
let v2_1_log = json!({
"version": "2.1.0",
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"runs": []
});
assert_eq!(
manager.detect_version(&v2_1_log).unwrap(),
SchemaVersion::V2_1_0
);
let v2_0_log = json!({
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.0.0.json",
"runs": []
});
assert_eq!(
manager.detect_version(&v2_0_log).unwrap(),
SchemaVersion::V2_0_0
);
}
#[test]
fn test_v2_0_to_v2_1_migration() {
let manager = SchemaEvolutionManager::new(SchemaEvolutionConfig {
target_version: SchemaVersion::V2_1_0,
..Default::default()
});
let v2_0_log = json!({
"version": "2.0.0",
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.0.0.json",
"runs": []
});
let result = manager.migrate(v2_0_log).unwrap();
assert_eq!(result.source_version, SchemaVersion::V2_0_0);
assert_eq!(result.target_version, SchemaVersion::V2_1_0);
assert!(!result.warnings.is_empty());
}
#[test]
fn test_schema_version_from_string() {
assert_eq!(
SchemaVersion::from_string("2.1.0").unwrap(),
SchemaVersion::V2_1_0
);
assert_eq!(
SchemaVersion::from_string("2.0.0").unwrap(),
SchemaVersion::V2_0_0
);
assert_eq!(
SchemaVersion::from_string("1.0.0").unwrap(),
SchemaVersion::V1_0_0
);
assert!(SchemaVersion::from_string("3.0.0").is_err());
}
#[test]
fn test_version_compatibility() {
assert!(SchemaVersion::V2_1_0.is_compatible_with(&SchemaVersion::V2_1_0));
assert!(SchemaVersion::V2_0_0.is_compatible_with(&SchemaVersion::V2_1_0));
assert!(SchemaVersion::V2_1_0.is_compatible_with(&SchemaVersion::V2_0_0));
assert!(!SchemaVersion::V1_0_0.is_compatible_with(&SchemaVersion::V2_0_0));
}
}