From c9bb6703dfc1182fde4b46cdf7c9f75a6b277948 Mon Sep 17 00:00:00 2001 From: Nathaniel Nanle Date: Wed, 29 Jul 2026 11:50:15 +0100 Subject: [PATCH] feat(upgrade): add contract safety analyzer --- README.md | 46 +++ docs/upgrade-analysis.schema.json | 71 ++++ src/commands/upgrade.rs | 103 +++++- src/main.rs | 9 +- src/utils/bindings.rs | 4 +- src/utils/mod.rs | 1 + src/utils/upgrade_analyzer.rs | 585 ++++++++++++++++++++++++++++++ tests/upgrade_analyzer_cli.rs | 146 ++++++++ 8 files changed, 959 insertions(+), 6 deletions(-) create mode 100644 docs/upgrade-analysis.schema.json create mode 100644 src/utils/upgrade_analyzer.rs create mode 100644 tests/upgrade_analyzer_cli.rs diff --git a/README.md b/README.md index 5ae43cb..ec1071c 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,52 @@ starforge contract generate-bindings ./my_contract.wasm --lang rust starforge contract generate-bindings ./my_contract.wasm --lang ts ``` +### Upgrade safety analysis + +Compare the currently deployed build with a candidate before creating an +upgrade proposal: + +```bash +starforge upgrade analyze \ + --current artifacts/current.wasm \ + --candidate target/wasm32-unknown-unknown/release/contract.wasm + +# Machine-readable report for CI and an audit artifact +starforge upgrade analyze \ + --current artifacts/current.wasm \ + --candidate target/wasm32-unknown-unknown/release/contract.wasm \ + --format json \ + --output upgrade-analysis.json +``` + +The command exits non-zero when it finds a breaking change. Interface findings +come from Soroban's embedded contract specification and have `confirmed` +confidence. Storage findings are `heuristic`: standard Soroban metadata does +not record storage durability or stored value types, so StarForge infers likely +keys from contract types conventionally named `DataKey` or `StorageKey` and +always asks you to verify the storage layout manually. + +The versioned JSON format is documented by +[`docs/upgrade-analysis.schema.json`](docs/upgrade-analysis.schema.json). + +Recommended GitHub Actions gate for an upgrade pull request: + +```yaml +- name: Analyze Soroban upgrade safety + run: | + starforge upgrade analyze \ + --current artifacts/production.wasm \ + --candidate target/wasm32-unknown-unknown/release/contract.wasm \ + --format json \ + --output upgrade-analysis.json +- name: Upload upgrade analysis + if: always() + uses: actions/upload-artifact@v4 + with: + name: upgrade-analysis + path: upgrade-analysis.json +``` + ### Environment info ```bash diff --git a/docs/upgrade-analysis.schema.json b/docs/upgrade-analysis.schema.json new file mode 100644 index 0000000..73dce3c --- /dev/null +++ b/docs/upgrade-analysis.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/StarsForges/StarForge/blob/master/docs/upgrade-analysis.schema.json", + "title": "StarForge Soroban Upgrade Analysis Report", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "current", "candidate", "summary", "findings"], + "properties": { + "schema_version": { + "const": "1.0" + }, + "current": { + "$ref": "#/$defs/artifact" + }, + "candidate": { + "$ref": "#/$defs/artifact" + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["breaking", "warnings", "info", "safe_to_upgrade"], + "properties": { + "breaking": { "type": "integer", "minimum": 0 }, + "warnings": { "type": "integer", "minimum": 0 }, + "info": { "type": "integer", "minimum": 0 }, + "safe_to_upgrade": { "type": "boolean" } + } + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "category", + "risk", + "confidence", + "code", + "subject", + "message", + "current", + "candidate" + ], + "properties": { + "category": { "enum": ["interface", "storage"] }, + "risk": { "enum": ["breaking", "warning", "info"] }, + "confidence": { "enum": ["confirmed", "heuristic"] }, + "code": { "type": "string", "minLength": 1 }, + "subject": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 }, + "current": { "type": ["string", "null"] }, + "candidate": { "type": ["string", "null"] } + } + } + } + }, + "$defs": { + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + } + } +} diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index 5929f3f..1cde517 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -1,5 +1,5 @@ -use crate::utils::{config, confirmation, horizon, print as p, soroban}; -use anyhow::Result; +use crate::utils::{config, confirmation, horizon, print as p, soroban, upgrade_analyzer}; +use anyhow::{Context, Result}; use chrono::Utc; use clap::{Args, Subcommand}; use colored::*; @@ -12,6 +12,8 @@ use std::path::PathBuf; #[derive(Subcommand)] pub enum UpgradeCommands { + /// Compare two contract WASMs for upgrade-breaking changes + Analyze(AnalyzeArgs), /// Prepare and validate a contract upgrade Prepare(PrepareArgs), /// Create a governance proposal for a contract upgrade @@ -30,6 +32,22 @@ pub enum UpgradeCommands { History(HistoryArgs), } +#[derive(Args)] +pub struct AnalyzeArgs { + /// Path to the currently deployed contract WASM + #[arg(long)] + pub current: PathBuf, + /// Path to the candidate contract WASM + #[arg(long)] + pub candidate: PathBuf, + /// Report format + #[arg(long, default_value = "human", value_parser = ["human", "json"])] + pub format: String, + /// Save the report to a file + #[arg(long)] + pub output: Option, +} + #[derive(Args)] pub struct PrepareArgs { /// Contract ID to upgrade @@ -271,6 +289,7 @@ fn short_id(id: &str) -> String { pub fn handle(cmd: UpgradeCommands) -> Result<()> { match cmd { + UpgradeCommands::Analyze(args) => handle_analyze(args), UpgradeCommands::Prepare(args) => handle_prepare(args), UpgradeCommands::Propose(args) => handle_propose(args), UpgradeCommands::List(args) => handle_list(args), @@ -282,6 +301,86 @@ pub fn handle(cmd: UpgradeCommands) -> Result<()> { } } +fn handle_analyze(args: AnalyzeArgs) -> Result<()> { + let report = upgrade_analyzer::analyze_paths(&args.current, &args.candidate)?; + let rendered = if args.format == "json" { + serde_json::to_string_pretty(&report)? + } else { + render_analysis_table(&report) + }; + + println!("{rendered}"); + if let Some(path) = args.output { + fs::write(&path, format!("{rendered}\n")) + .with_context(|| format!("Failed to write analysis report {}", path.display()))?; + if args.format != "json" { + p::info(&format!("Report saved to {}", path.display())); + } + } + + if report.summary.breaking > 0 { + anyhow::bail!( + "upgrade analysis found {} breaking finding(s)", + report.summary.breaking + ); + } + Ok(()) +} + +fn render_analysis_table(report: &upgrade_analyzer::UpgradeReport) -> String { + use upgrade_analyzer::{Confidence, FindingCategory, Risk}; + + let mut output = String::new(); + output.push_str("Soroban Contract Upgrade Safety Report\n"); + output.push_str(&format!("Current: {}\n", report.current.path)); + output.push_str(&format!("Candidate: {}\n\n", report.candidate.path)); + output.push_str(&format!( + "{:<10} {:<10} {:<11} {:<28} {}\n", + "RISK", "AREA", "CONFIDENCE", "SUBJECT", "CHANGE" + )); + output.push_str(&format!("{}\n", "─".repeat(100))); + + for finding in &report.findings { + let risk = match finding.risk { + Risk::Breaking => "breaking", + Risk::Warning => "warning", + Risk::Info => "info", + }; + let area = match finding.category { + FindingCategory::Interface => "interface", + FindingCategory::Storage => "storage", + }; + let confidence = match finding.confidence { + Confidence::Confirmed => "confirmed", + Confidence::Heuristic => "heuristic", + }; + output.push_str(&format!( + "{:<10} {:<10} {:<11} {:<28} {}\n", + risk, area, confidence, finding.subject, finding.message + )); + if finding.current.is_some() || finding.candidate.is_some() { + output.push_str(&format!( + " current: {} | candidate: {}\n", + finding.current.as_deref().unwrap_or("—"), + finding.candidate.as_deref().unwrap_or("—") + )); + } + } + + output.push_str(&format!( + "\nSummary: {} breaking, {} warning, {} info — {}\n", + report.summary.breaking, + report.summary.warnings, + report.summary.info, + if report.summary.safe_to_upgrade { + "no breaking changes found" + } else { + "upgrade blocked" + } + )); + output +} + fn handle_prepare(args: PrepareArgs) -> Result<()> { p::header("Prepare Contract Upgrade"); diff --git a/src/main.rs b/src/main.rs index f7b30bc..1722a54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -119,6 +119,11 @@ enum Commands { fn main() { let cli = Cli::parse(); + let machine_readable = matches!( + &cli.command, + Commands::Upgrade(commands::upgrade::UpgradeCommands::Analyze(args)) + if args.format == "json" + ); // Initialise structured logging before anything else runs. let log_cfg = @@ -127,12 +132,12 @@ fn main() { eprintln!("Warning: failed to initialise logger: {}", e); } - if !cli.quiet { + if !cli.quiet && !machine_readable { print_banner(); } // On first run after a schema version change, re-display the telemetry notice. - if !cli.quiet { + if !cli.quiet && !machine_readable { if let Ok(true) = utils::telemetry::schema_version_changed() { eprintln!( "\n {} Telemetry schema updated to v{}. starforge stores only: \ diff --git a/src/utils/bindings.rs b/src/utils/bindings.rs index a66d6f4..2e97761 100644 --- a/src/utils/bindings.rs +++ b/src/utils/bindings.rs @@ -44,7 +44,7 @@ pub fn generate_bindings(wasm_path: &Path, language: BindingLanguage) -> Result< } } -fn read_spec_entries(wasm: &[u8]) -> Result> { +pub(crate) fn read_spec_entries(wasm: &[u8]) -> Result> { let spec = contract_spec_section(wasm)?; let cursor = Cursor::new(spec); let entries = ScSpecEntry::read_xdr_iter(&mut Limited::new( @@ -131,7 +131,7 @@ fn contract_function(function: &ScSpecFunctionV0) -> ContractFunction { } } -fn spec_type_name(type_def: &ScSpecTypeDef) -> String { +pub(crate) fn spec_type_name(type_def: &ScSpecTypeDef) -> String { match type_def { ScSpecTypeDef::Val => "Val".to_string(), ScSpecTypeDef::Bool => "bool".to_string(), diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ede92d6..2dc748a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -25,3 +25,4 @@ pub mod templates; pub mod test_runner; pub mod tutorial_engine; pub mod tx_batch; +pub mod upgrade_analyzer; diff --git a/src/utils/upgrade_analyzer.rs b/src/utils/upgrade_analyzer.rs new file mode 100644 index 0000000..147cced --- /dev/null +++ b/src/utils/upgrade_analyzer.rs @@ -0,0 +1,585 @@ +//! Static compatibility analysis for Soroban contract upgrades. +//! +//! Function specifications are part of Soroban's `contractspecv0` metadata and +//! are therefore treated as confirmed evidence. Storage schemas are not part of +//! the standard metadata. We can only infer likely keys from contract types +//! conventionally named `DataKey`/`StorageKey`; every such finding is marked as +//! heuristic so callers do not mistake it for full storage-layout recovery. + +use crate::utils::bindings::{read_spec_entries, spec_type_name}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; +use stellar_xdr::curr::{ScSpecEntry, ScSpecUdtUnionCaseV0}; + +pub const REPORT_SCHEMA_VERSION: &str = "1.0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Risk { + Breaking, + Warning, + Info, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Confidence { + Confirmed, + Heuristic, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FindingCategory { + Interface, + Storage, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Finding { + pub category: FindingCategory, + pub risk: Risk, + pub confidence: Confidence, + pub code: String, + pub subject: String, + pub message: String, + pub current: Option, + pub candidate: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Artifact { + pub path: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Summary { + pub breaking: usize, + pub warnings: usize, + pub info: usize, + pub safe_to_upgrade: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpgradeReport { + pub schema_version: String, + pub current: Artifact, + pub candidate: Artifact, + pub summary: Summary, + pub findings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FunctionSpec { + inputs: Vec, + outputs: Vec, +} + +impl FunctionSpec { + fn display(&self) -> String { + let output = match self.outputs.as_slice() { + [] => "()".to_string(), + [only] => only.clone(), + many => format!("({})", many.join(", ")), + }; + format!("({}) -> {}", self.inputs.join(", "), output) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StorageKeySpec { + shape: String, +} + +pub fn analyze_paths(current: &Path, candidate: &Path) -> Result { + let current_wasm = fs::read(current) + .with_context(|| format!("Failed to read current WASM {}", current.display()))?; + let candidate_wasm = fs::read(candidate) + .with_context(|| format!("Failed to read candidate WASM {}", candidate.display()))?; + + analyze_bytes( + ¤t_wasm, + current.display().to_string(), + &candidate_wasm, + candidate.display().to_string(), + ) +} + +pub fn analyze_bytes( + current_wasm: &[u8], + current_path: String, + candidate_wasm: &[u8], + candidate_path: String, +) -> Result { + let current_entries = read_spec_entries(current_wasm) + .with_context(|| format!("Could not inspect current contract ({current_path})"))?; + let candidate_entries = read_spec_entries(candidate_wasm) + .with_context(|| format!("Could not inspect candidate contract ({candidate_path})"))?; + + let mut findings = diff_interfaces(¤t_entries, &candidate_entries); + findings.extend(diff_storage(¤t_entries, &candidate_entries)); + findings.sort_by(|a, b| { + risk_order(a.risk) + .cmp(&risk_order(b.risk)) + .then(a.category_string().cmp(b.category_string())) + .then(a.subject.cmp(&b.subject)) + }); + + let breaking = findings + .iter() + .filter(|finding| finding.risk == Risk::Breaking) + .count(); + let warnings = findings + .iter() + .filter(|finding| finding.risk == Risk::Warning) + .count(); + let info = findings + .iter() + .filter(|finding| finding.risk == Risk::Info) + .count(); + + Ok(UpgradeReport { + schema_version: REPORT_SCHEMA_VERSION.to_string(), + current: Artifact { + path: current_path, + sha256: sha256(current_wasm), + }, + candidate: Artifact { + path: candidate_path, + sha256: sha256(candidate_wasm), + }, + summary: Summary { + breaking, + warnings, + info, + safe_to_upgrade: breaking == 0, + }, + findings, + }) +} + +impl Finding { + fn category_string(&self) -> &'static str { + match self.category { + FindingCategory::Interface => "interface", + FindingCategory::Storage => "storage", + } + } +} + +fn risk_order(risk: Risk) -> u8 { + match risk { + Risk::Breaking => 0, + Risk::Warning => 1, + Risk::Info => 2, + } +} + +fn sha256(bytes: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(bytes); + hex::encode(digest.finalize()) +} + +fn interface(entries: &[ScSpecEntry]) -> BTreeMap { + entries + .iter() + .filter_map(|entry| match entry { + ScSpecEntry::FunctionV0(function) => Some(( + function.name.to_string(), + FunctionSpec { + inputs: function + .inputs + .iter() + .map(|input| spec_type_name(&input.type_)) + .collect(), + outputs: function.outputs.iter().map(spec_type_name).collect(), + }, + )), + _ => None, + }) + .collect() +} + +fn diff_interfaces(current: &[ScSpecEntry], candidate: &[ScSpecEntry]) -> Vec { + let current = interface(current); + let candidate = interface(candidate); + let names = current + .keys() + .chain(candidate.keys()) + .cloned() + .collect::>(); + let mut findings = Vec::new(); + + for name in names { + match (current.get(&name), candidate.get(&name)) { + (Some(old), None) => findings.push(Finding { + category: FindingCategory::Interface, + risk: Risk::Breaking, + confidence: Confidence::Confirmed, + code: "interface.function_removed".to_string(), + subject: name.clone(), + message: format!("Public function `{name}` was removed or renamed"), + current: Some(old.display()), + candidate: None, + }), + (None, Some(new)) => findings.push(Finding { + category: FindingCategory::Interface, + risk: Risk::Info, + confidence: Confidence::Confirmed, + code: "interface.function_added".to_string(), + subject: name.clone(), + message: format!("Public function `{name}` was added"), + current: None, + candidate: Some(new.display()), + }), + (Some(old), Some(new)) if old != new => findings.push(Finding { + category: FindingCategory::Interface, + risk: Risk::Breaking, + confidence: Confidence::Confirmed, + code: "interface.signature_changed".to_string(), + subject: name.clone(), + message: format!("Public function `{name}` changed its signature"), + current: Some(old.display()), + candidate: Some(new.display()), + }), + (Some(old), Some(_)) => findings.push(Finding { + category: FindingCategory::Interface, + risk: Risk::Info, + confidence: Confidence::Confirmed, + code: "interface.signature_unchanged".to_string(), + subject: name.clone(), + message: format!("Public function `{name}` is unchanged"), + current: Some(old.display()), + candidate: Some(old.display()), + }), + (None, None) => unreachable!(), + } + } + findings +} + +fn looks_like_storage_key_type(name: &str) -> bool { + let normalized = name + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::(); + matches!(normalized.as_str(), "key" | "datakey" | "storagekey") + || (normalized.contains("storage") && normalized.ends_with("key")) +} + +fn storage_keys(entries: &[ScSpecEntry]) -> BTreeMap { + let mut keys = BTreeMap::new(); + for entry in entries { + match entry { + ScSpecEntry::UdtUnionV0(union) + if looks_like_storage_key_type(&union.name.to_string()) => + { + let type_name = union.name.to_string(); + for case in union.cases.iter() { + let (name, shape) = match case { + ScSpecUdtUnionCaseV0::VoidV0(case) => { + (case.name.to_string(), "unit".to_string()) + } + ScSpecUdtUnionCaseV0::TupleV0(case) => ( + case.name.to_string(), + format!( + "({})", + case.type_ + .iter() + .map(spec_type_name) + .collect::>() + .join(", ") + ), + ), + }; + keys.insert(format!("{type_name}::{name}"), StorageKeySpec { shape }); + } + } + ScSpecEntry::UdtEnumV0(enumeration) + if looks_like_storage_key_type(&enumeration.name.to_string()) => + { + let type_name = enumeration.name.to_string(); + for case in enumeration.cases.iter() { + keys.insert( + format!("{type_name}::{}", case.name), + StorageKeySpec { + shape: format!("integer discriminant {}", case.value), + }, + ); + } + } + ScSpecEntry::UdtStructV0(structure) + if looks_like_storage_key_type(&structure.name.to_string()) => + { + keys.insert( + structure.name.to_string(), + StorageKeySpec { + shape: format!( + "{{{}}}", + structure + .fields + .iter() + .map(|field| { + format!("{}: {}", field.name, spec_type_name(&field.type_)) + }) + .collect::>() + .join(", ") + ), + }, + ); + } + _ => {} + } + } + keys +} + +fn diff_storage(current: &[ScSpecEntry], candidate: &[ScSpecEntry]) -> Vec { + let current = storage_keys(current); + let candidate = storage_keys(candidate); + let names = current + .keys() + .chain(candidate.keys()) + .cloned() + .collect::>(); + let mut findings = Vec::new(); + + for name in names { + match (current.get(&name), candidate.get(&name)) { + (Some(old), None) => findings.push(Finding { + category: FindingCategory::Storage, + risk: Risk::Breaking, + confidence: Confidence::Heuristic, + code: "storage.key_removed".to_string(), + subject: name.clone(), + message: format!( + "Likely storage key `{name}` was removed; verify all existing ledger entries manually" + ), + current: Some(old.shape.clone()), + candidate: None, + }), + (None, Some(new)) => findings.push(Finding { + category: FindingCategory::Storage, + risk: Risk::Info, + confidence: Confidence::Heuristic, + code: "storage.key_added".to_string(), + subject: name.clone(), + message: format!("Likely storage key `{name}` was added"), + current: None, + candidate: Some(new.shape.clone()), + }), + (Some(old), Some(new)) if old != new => findings.push(Finding { + category: FindingCategory::Storage, + risk: Risk::Breaking, + confidence: Confidence::Heuristic, + code: "storage.key_shape_changed".to_string(), + subject: name.clone(), + message: format!( + "Likely storage key `{name}` changed shape; existing entries may become unreadable" + ), + current: Some(old.shape.clone()), + candidate: Some(new.shape.clone()), + }), + _ => {} + } + } + + findings.push(Finding { + category: FindingCategory::Storage, + risk: Risk::Warning, + confidence: Confidence::Heuristic, + code: "storage.analysis_limited".to_string(), + subject: "storage layout".to_string(), + message: if current.is_empty() && candidate.is_empty() { + "No conventional DataKey/StorageKey contract types were found. Standard Soroban metadata does not expose storage scopes or stored value types; verify the storage layout manually." + .to_string() + } else { + "Keys were inferred from conventionally named contract types. Standard Soroban metadata does not confirm that they are used for storage, their durability scope, or their stored value types; verify these manually." + .to_string() + }, + current: None, + candidate: None, + }); + findings +} + +#[cfg(test)] +mod tests { + use super::*; + use stellar_xdr::curr::{ + Limits, ScSpecFunctionInputV0, ScSpecFunctionV0, ScSpecTypeDef, ScSpecUdtUnionCaseTupleV0, + ScSpecUdtUnionCaseV0, ScSpecUdtUnionV0, ScSymbol, StringM, VecM, WriteXdr, + }; + + fn function(name: &str, input: ScSpecTypeDef) -> ScSpecEntry { + ScSpecEntry::FunctionV0(ScSpecFunctionV0 { + doc: StringM::default(), + name: ScSymbol(StringM::try_from(name.as_bytes().to_vec()).unwrap()), + inputs: VecM::try_from(vec![ScSpecFunctionInputV0 { + doc: StringM::default(), + name: StringM::try_from(b"value".to_vec()).unwrap(), + type_: input, + }]) + .unwrap(), + outputs: VecM::try_from(vec![ScSpecTypeDef::Bool]).unwrap(), + }) + } + + fn data_key(case_type: ScSpecTypeDef) -> ScSpecEntry { + ScSpecEntry::UdtUnionV0(ScSpecUdtUnionV0 { + doc: StringM::default(), + lib: StringM::default(), + name: StringM::try_from(b"DataKey".to_vec()).unwrap(), + cases: VecM::try_from(vec![ScSpecUdtUnionCaseV0::TupleV0( + ScSpecUdtUnionCaseTupleV0 { + doc: StringM::default(), + name: StringM::try_from(b"Balance".to_vec()).unwrap(), + type_: VecM::try_from(vec![case_type]).unwrap(), + }, + )]) + .unwrap(), + }) + } + + fn wasm_with_spec(entries: &[ScSpecEntry]) -> Vec { + let mut spec = Vec::new(); + for entry in entries { + spec.extend(entry.to_xdr(Limits::none()).unwrap()); + } + + let name = b"contractspecv0"; + let mut section = Vec::new(); + push_var_u32(&mut section, name.len() as u32); + section.extend(name); + section.extend(spec); + + let mut wasm = b"\0asm\x01\0\0\0".to_vec(); + wasm.push(0); + push_var_u32(&mut wasm, section.len() as u32); + wasm.extend(section); + wasm + } + + fn push_var_u32(output: &mut Vec, mut value: u32) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + output.push(byte); + if value == 0 { + break; + } + } + } + + #[test] + fn additive_interface_change_has_no_breaking_findings() { + let old = vec![function("set", ScSpecTypeDef::U32)]; + let new = vec![ + function("set", ScSpecTypeDef::U32), + function("get", ScSpecTypeDef::U32), + ]; + let findings = diff_interfaces(&old, &new); + assert!(!findings.iter().any(|f| f.risk == Risk::Breaking)); + assert!(findings + .iter() + .any(|f| f.code == "interface.function_added" && f.subject == "get")); + } + + #[test] + fn changed_argument_type_is_breaking_and_names_function() { + let old = vec![function("set", ScSpecTypeDef::U32)]; + let new = vec![function("set", ScSpecTypeDef::I64)]; + let finding = diff_interfaces(&old, &new) + .into_iter() + .find(|f| f.risk == Risk::Breaking) + .unwrap(); + assert_eq!(finding.subject, "set"); + assert_eq!(finding.code, "interface.signature_changed"); + assert_eq!(finding.current.as_deref(), Some("(u32) -> bool")); + assert_eq!(finding.candidate.as_deref(), Some("(i64) -> bool")); + } + + #[test] + fn paired_wasm_addition_is_safe_but_signature_change_is_not() { + let current = wasm_with_spec(&[function("set", ScSpecTypeDef::U32)]); + let additive = wasm_with_spec(&[ + function("set", ScSpecTypeDef::U32), + function("get", ScSpecTypeDef::U32), + ]); + let breaking = wasm_with_spec(&[function("set", ScSpecTypeDef::I64)]); + + let additive_report = analyze_bytes( + ¤t, + "current.wasm".to_string(), + &additive, + "additive.wasm".to_string(), + ) + .unwrap(); + assert!(additive_report.summary.safe_to_upgrade); + assert_eq!(additive_report.summary.breaking, 0); + + let breaking_report = analyze_bytes( + ¤t, + "current.wasm".to_string(), + &breaking, + "breaking.wasm".to_string(), + ) + .unwrap(); + assert!(!breaking_report.summary.safe_to_upgrade); + assert_eq!(breaking_report.summary.breaking, 1); + assert!(breaking_report.findings.iter().any(|finding| { + finding.code == "interface.signature_changed" && finding.subject == "set" + })); + } + + #[test] + fn storage_key_shape_change_is_breaking_but_heuristic() { + let findings = diff_storage( + &[data_key(ScSpecTypeDef::Address)], + &[data_key(ScSpecTypeDef::U32)], + ); + let finding = findings + .iter() + .find(|finding| finding.code == "storage.key_shape_changed") + .unwrap(); + assert_eq!(finding.risk, Risk::Breaking); + assert_eq!(finding.confidence, Confidence::Heuristic); + assert_eq!(finding.subject, "DataKey::Balance"); + } + + #[test] + fn report_json_has_stable_ci_fields() { + let report = UpgradeReport { + schema_version: REPORT_SCHEMA_VERSION.to_string(), + current: Artifact { + path: "old.wasm".to_string(), + sha256: "a".repeat(64), + }, + candidate: Artifact { + path: "new.wasm".to_string(), + sha256: "b".repeat(64), + }, + summary: Summary { + breaking: 0, + warnings: 1, + info: 1, + safe_to_upgrade: true, + }, + findings: vec![], + }; + let json = serde_json::to_value(report).unwrap(); + assert_eq!(json["schema_version"], "1.0"); + assert_eq!(json["summary"]["safe_to_upgrade"], true); + assert!(json["findings"].is_array()); + } +} diff --git a/tests/upgrade_analyzer_cli.rs b/tests/upgrade_analyzer_cli.rs new file mode 100644 index 0000000..5645fc6 --- /dev/null +++ b/tests/upgrade_analyzer_cli.rs @@ -0,0 +1,146 @@ +use serde_json::Value; +use std::fs; +use std::process::Command; +use stellar_xdr::curr::{ + Limits, ScSpecEntry, ScSpecFunctionInputV0, ScSpecFunctionV0, ScSpecTypeDef, ScSymbol, StringM, + VecM, WriteXdr, +}; + +fn function(name: &str, input: ScSpecTypeDef) -> ScSpecEntry { + ScSpecEntry::FunctionV0(ScSpecFunctionV0 { + doc: StringM::default(), + name: ScSymbol(StringM::try_from(name.as_bytes().to_vec()).unwrap()), + inputs: VecM::try_from(vec![ScSpecFunctionInputV0 { + doc: StringM::default(), + name: StringM::try_from(b"value".to_vec()).unwrap(), + type_: input, + }]) + .unwrap(), + outputs: VecM::try_from(vec![ScSpecTypeDef::Bool]).unwrap(), + }) +} + +fn wasm_with_spec(entries: &[ScSpecEntry]) -> Vec { + let mut spec = Vec::new(); + for entry in entries { + spec.extend(entry.to_xdr(Limits::none()).unwrap()); + } + + let name = b"contractspecv0"; + let mut section = Vec::new(); + push_var_u32(&mut section, name.len() as u32); + section.extend(name); + section.extend(spec); + + let mut wasm = b"\0asm\x01\0\0\0".to_vec(); + wasm.push(0); + push_var_u32(&mut wasm, section.len() as u32); + wasm.extend(section); + wasm +} + +fn push_var_u32(output: &mut Vec, mut value: u32) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + output.push(byte); + if value == 0 { + break; + } + } +} + +#[test] +fn additive_upgrade_exits_zero_and_writes_json_report() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("current.wasm"); + let candidate = dir.path().join("candidate.wasm"); + let report_path = dir.path().join("report.json"); + fs::write( + ¤t, + wasm_with_spec(&[function("set", ScSpecTypeDef::U32)]), + ) + .unwrap(); + fs::write( + &candidate, + wasm_with_spec(&[ + function("set", ScSpecTypeDef::U32), + function("get", ScSpecTypeDef::U32), + ]), + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_starforge")) + .args([ + "upgrade", + "analyze", + "--current", + current.to_str().unwrap(), + "--candidate", + candidate.to_str().unwrap(), + "--format", + "json", + "--output", + report_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout: Value = serde_json::from_slice(&output.stdout).unwrap(); + let saved: Value = serde_json::from_slice(&fs::read(report_path).unwrap()).unwrap(); + assert_eq!(stdout, saved); + assert_eq!(stdout["schema_version"], "1.0"); + assert_eq!(stdout["summary"]["breaking"], 0); + assert_eq!(stdout["summary"]["safe_to_upgrade"], true); +} + +#[test] +fn breaking_signature_exits_nonzero_and_identifies_function() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("current.wasm"); + let candidate = dir.path().join("candidate.wasm"); + fs::write( + ¤t, + wasm_with_spec(&[function("set", ScSpecTypeDef::U32)]), + ) + .unwrap(); + fs::write( + &candidate, + wasm_with_spec(&[function("set", ScSpecTypeDef::I64)]), + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_starforge")) + .args([ + "upgrade", + "analyze", + "--current", + current.to_str().unwrap(), + "--candidate", + candidate.to_str().unwrap(), + "--format", + "json", + ]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["summary"]["breaking"], 1); + assert_eq!(report["summary"]["safe_to_upgrade"], false); + assert!(report["findings"] + .as_array() + .unwrap() + .iter() + .any(|finding| { + finding["code"] == "interface.signature_changed" && finding["subject"] == "set" + })); +}