diff --git a/crates/cli/src/output/renderers.rs b/crates/cli/src/output/renderers.rs index 29b2cbb0..a1ca5008 100644 --- a/crates/cli/src/output/renderers.rs +++ b/crates/cli/src/output/renderers.rs @@ -512,6 +512,8 @@ mod tests { failing_contract_id: None, call_chain: None, resource_diagnostics: None, + operation_index: None, + operation_count: None, learn_more: "https://developers.stellar.org/docs/learn/smart-contracts/errors" .to_string(), } @@ -596,6 +598,8 @@ mod tests { write_bytes: 500, }, return_value: None, + operation_index: None, + operation_count: None, }; let output = render_context_table(&context); diff --git a/crates/core/src/bin/taxonomy_linter.rs b/crates/core/src/bin/taxonomy_linter.rs index d2c6c903..56999759 100644 --- a/crates/core/src/bin/taxonomy_linter.rs +++ b/crates/core/src/bin/taxonomy_linter.rs @@ -25,26 +25,14 @@ fn main() { process::exit(1); } - let issues = match grat_core::taxonomy::linter::lint_dir(&dir) { - Ok(issues) => issues, + match grat_core::taxonomy::linter::lint_dir(&dir) { + Ok(()) => { + println!("✅ No issues found in {}", dir.display()); + process::exit(0); + } Err(e) => { - eprintln!("error: lint_dir failed: {e}"); + eprintln!("error: lint_dir execution failed: {e}"); process::exit(1); } - }; - - if issues.is_empty() { - println!("✅ No issues found in {}", dir.display()); - process::exit(0); - } - - for issue in &issues { - match &issue.entry_id { - Some(eid) => eprintln!("{}:{}: {}", issue.file, eid, issue.message), - None => eprintln!("{}:: {}", issue.file, issue.message), - } } - - eprintln!("❌ Found {} issue(s) in {}", issues.len(), dir.display()); - process::exit(1); } diff --git a/crates/core/src/cache/store.rs b/crates/core/src/cache/store.rs index e4e313b0..ba0329d9 100644 --- a/crates/core/src/cache/store.rs +++ b/crates/core/src/cache/store.rs @@ -87,8 +87,13 @@ impl CacheStore { let path = self.entry_path(category, key); if path.exists() { // Explicitly update access metadata to ensure LRU eviction works even if atime is disabled. - if let Ok(file) = std::fs::File::open(&path) { - let _ = file.set_times(std::fs::FileTimes::new().set_accessed(SystemTime::now())); + if let Ok(file) = std::fs::OpenOptions::new().write(true).open(&path) { + let now = SystemTime::now(); + let _ = file.set_times( + std::fs::FileTimes::new() + .set_accessed(now) + .set_modified(now), + ); } let data = std::fs::read(&path) .map_err(|e| GratError::CacheError(format!("Failed to read cache entry: {e}")))?; @@ -160,12 +165,11 @@ impl CacheStore { GratError::CacheError(format!("Failed to read cache file metadata: {e}")) })?; - let accessed = meta - .accessed() - .or_else(|_| meta.modified()) - .unwrap_or(SystemTime::UNIX_EPOCH); + let accessed = meta.accessed().unwrap_or(SystemTime::UNIX_EPOCH); + let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); + let last_used = std::cmp::max(accessed, modified); - files.push((accessed, entry)); + files.push((last_used, entry)); } } diff --git a/crates/core/src/decode/argument_decoder.rs b/crates/core/src/decode/argument_decoder.rs index 92c05aac..2e5d9041 100644 --- a/crates/core/src/decode/argument_decoder.rs +++ b/crates/core/src/decode/argument_decoder.rs @@ -113,9 +113,7 @@ mod tests { use super::*; use crate::spec::decoder::{ContractStructDef, ContractStructField}; use serde_json::json; - use stellar_xdr::curr::{ - Hash, ScAddress, ScMapEntry, ScString, ScSymbol, ScVal, - }; + use stellar_xdr::curr::{Hash, ScAddress, ScMapEntry, ScString, ScSymbol, ScVal}; fn sym(s: &str) -> ScVal { ScVal::Symbol(ScSymbol(s.try_into().unwrap())) @@ -271,7 +269,10 @@ mod tests { let decoder = ArgumentDecoder::new(); let func = ContractFunction { name: "transfer".to_string(), - params: vec![("to".to_string(), "Address".to_string()), ("amount".to_string(), "U128".to_string())], + params: vec![ + ("to".to_string(), "Address".to_string()), + ("amount".to_string(), "U128".to_string()), + ], return_type: "Void".to_string(), doc: None, return_type_def: Some(ScSpecTypeDef::Void), diff --git a/crates/core/src/decode/chain_analyzer.rs b/crates/core/src/decode/chain_analyzer.rs index 287d201a..d590b569 100644 --- a/crates/core/src/decode/chain_analyzer.rs +++ b/crates/core/src/decode/chain_analyzer.rs @@ -323,7 +323,6 @@ impl ChainAnalyzer { chain.trace = chain.render_trace(); chain } - } // --------------------------------------------------------------------------- @@ -488,7 +487,7 @@ impl DeepestErrorFinder { // Evaluate every failure event, not just the first // ---------------------------------------------------------------- if is_failure(event, &topics, &v0.data) { - let depth = stack.len(); + let depth = stack.len().saturating_sub(1); let is_new_deepest = match &deepest { None => true, @@ -500,7 +499,9 @@ impl DeepestErrorFinder { if is_new_deepest { let (contract_address, function_name) = match stack.last() { - Some(frame) => (frame.contract_address.clone(), frame.function_name.clone()), + Some(frame) => { + (frame.contract_address.clone(), frame.function_name.clone()) + } None => { let address = event .event diff --git a/crates/core/src/decode/function_call_decoder.rs b/crates/core/src/decode/function_call_decoder.rs index 8d956ce1..f7dac3a4 100644 --- a/crates/core/src/decode/function_call_decoder.rs +++ b/crates/core/src/decode/function_call_decoder.rs @@ -89,7 +89,10 @@ mod tests { let decoder = FunctionCallDecoder::new(); let func = ContractFunction { name: "add".to_string(), - params: vec![("a".to_string(), "U32".to_string()), ("b".to_string(), "U32".to_string())], + params: vec![ + ("a".to_string(), "U32".to_string()), + ("b".to_string(), "U32".to_string()), + ], return_type: "U32".to_string(), doc: None, return_type_def: Some(ScSpecTypeDef::U32), @@ -102,13 +105,7 @@ mod tests { let raw_args = vec![ScVal::U32(10), ScVal::U32(20)]; let return_val = ScVal::U32(30); - let decoded = decoder.decode( - "add", - &raw_args, - Some(&func), - Some(&return_val), - None, - ); + let decoded = decoder.decode("add", &raw_args, Some(&func), Some(&return_val), None); assert_eq!(decoded.function_name, "add"); assert_eq!(decoded.arguments.len(), 2); @@ -125,13 +122,7 @@ mod tests { let decoder = FunctionCallDecoder::new(); let raw_args = vec![ScVal::U32(5)]; - let decoded = decoder.decode( - "test_func", - &raw_args, - None, - None, - None, - ); + let decoded = decoder.decode("test_func", &raw_args, None, None, None); assert_eq!(decoded.function_name, "test_func"); assert_eq!(decoded.arguments.len(), 1); diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index 110a0858..5d64f9be 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -15,8 +15,8 @@ pub mod host_error; pub mod mappings; pub mod multi_op_decoder; pub mod report; -pub mod return_decoder; pub mod resource_analyzer; +pub mod return_decoder; pub mod scval_to_json; pub mod walker; @@ -26,13 +26,13 @@ pub use auth::{ AuthorizationType, }; pub use auth_address_nonce::AddressWithNonce; -pub use function_call_decoder::{DecodedArgument, DecodedFunctionCall, FunctionCallDecoder}; -pub use return_decoder::ReturnValueDecoder; pub use chain_analyzer::{analyze_call_chain, CallChain, ChainAnalyzer, ChainFrame, FrameRole}; +pub use function_call_decoder::{DecodedArgument, DecodedFunctionCall, FunctionCallDecoder}; +pub use multi_op_decoder::{decode_transaction_with_op_filter, MultiOpDecoder}; pub use resource_analyzer::{ MetricDiagnostic, MetricKind, ResourceDiagnostics, ResourceUsageAnalyzer, TransactionResultMeta, }; -pub use multi_op_decoder::{decode_transaction_with_op_filter, MultiOpDecoder}; +pub use return_decoder::ReturnValueDecoder; pub use scval_to_json::scval_to_json; pub use walker::{ walk_diagnostic_events, DiagnosticEventKind, DiagnosticEventWalker, StructuredDiagnosticEvent, diff --git a/crates/core/src/decode/multi_op_decoder.rs b/crates/core/src/decode/multi_op_decoder.rs index 193e109d..b8cf8e7c 100644 --- a/crates/core/src/decode/multi_op_decoder.rs +++ b/crates/core/src/decode/multi_op_decoder.rs @@ -6,10 +6,10 @@ use crate::rpc::SorobanRpcClient; use crate::types::report::DiagnosticReport; use crate::xdr::codec::XdrCodec; use stellar_xdr::curr::{ - ContractEvent, ContractEventBody, DiagnosticEvent, Operation, OperationBody, - OperationResult, OperationResultTr, SorobanTransactionMeta, SorobanTransactionMetaExt, - TransactionEnvelope, TransactionMeta, TransactionMetaV3, TransactionResult, - TransactionResultResult, TxV1Envelope, + ContractEvent, ContractEventBody, DiagnosticEvent, Operation, OperationBody, OperationResult, + OperationResultTr, SorobanTransactionMeta, SorobanTransactionMetaExt, TransactionEnvelope, + TransactionMeta, TransactionMetaV3, TransactionResult, TransactionResultResult, + TransactionV1Envelope, }; use stellar_xdr::curr::{FeeBumpTransactionInnerTx, ScVal}; @@ -36,17 +36,23 @@ impl MultiOpDecoder { let envelope_xdr = tx_data .get("envelopeXdr") .and_then(|v| v.as_str()) - .ok_or_else(|| crate::error::GratError::Internal( - "Missing envelopeXdr in transaction data".to_string(), - ))?; + .ok_or_else(|| { + crate::error::GratError::Internal( + "Missing envelopeXdr in transaction data".to_string(), + ) + })?; - let envelope = ::from_xdr_base64(envelope_xdr) - .map_err(|e| crate::error::GratError::Internal(format!("Failed to decode envelope XDR: {}", e)))?; + let envelope = + ::from_xdr_base64(envelope_xdr).map_err(|e| { + crate::error::GratError::Internal(format!("Failed to decode envelope XDR: {}", e)) + })?; let num_ops = match &envelope { - TransactionEnvelope::Tx(TxV1Envelope { tx, .. }) => tx.operations.len(), + TransactionEnvelope::Tx(TransactionV1Envelope { tx, .. }) => tx.operations.len(), TransactionEnvelope::TxFeeBump(fb) => match &fb.tx.inner_tx { - FeeBumpTransactionInnerTx::Tx(TxV1Envelope { tx, .. }) => tx.operations.len(), + FeeBumpTransactionInnerTx::Tx(TransactionV1Envelope { tx, .. }) => { + tx.operations.len() + } }, TransactionEnvelope::TxV0(_) => 1, }; @@ -54,20 +60,24 @@ impl MultiOpDecoder { let result_xdr = tx_data .get("resultXdr") .and_then(|v| v.as_str()) - .ok_or_else(|| crate::error::GratError::Internal( - "Missing resultXdr in transaction data".to_string(), - ))?; + .ok_or_else(|| { + crate::error::GratError::Internal( + "Missing resultXdr in transaction data".to_string(), + ) + })?; - let tx_result = ::from_xdr_base64(result_xdr) - .map_err(|e| crate::error::GratError::Internal(format!("Failed to decode result XDR: {}", e)))?; + let tx_result = + ::from_xdr_base64(result_xdr).map_err(|e| { + crate::error::GratError::Internal(format!("Failed to decode result XDR: {}", e)) + })?; let op_results = match tx_result.result { TransactionResultResult::TxSuccess(ops) => ops, TransactionResultResult::TxFailed(ops) => ops, TransactionResultResult::TxFeeBumpInnerSuccess(_) => { - return Ok(vec![build_report(&classify_error(tx_data)?).map_err(|e| { - crate::error::GratError::Internal(format!("{}", e)) - })?]) + return Ok(vec![build_report(&classify_error(tx_data)?).map_err( + |e| crate::error::GratError::Internal(format!("{}", e)), + )?]) } _ => { return Err(crate::error::GratError::NotSorobanTransaction.into()); @@ -78,32 +88,31 @@ impl MultiOpDecoder { .get("resultMetaXdr") .and_then(|v| v.as_str()) .map(|xdr| { - ::from_xdr_base64(xdr) - .map_err(|e| crate::error::GratError::Internal(format!("Failed to decode meta XDR: {}", e))) + ::from_xdr_base64(xdr).map_err(|e| { + crate::error::GratError::Internal(format!("Failed to decode meta XDR: {}", e)) + }) }) .transpose()?; - let soroban_meta = meta_xdr - .and_then(|meta| match meta { - TransactionMeta::V3(v3) => v3.soroban_meta, - TransactionMeta::V0(_) => None, - TransactionMeta::V1(_) => None, - TransactionMeta::V2(_) => None, - }); + let soroban_meta = meta_xdr.and_then(|meta| match meta { + TransactionMeta::V3(v3) => v3.soroban_meta, + TransactionMeta::V0(_) => None, + TransactionMeta::V1(_) => None, + TransactionMeta::V2(_) => None, + }); let all_diagnostic_events = soroban_meta .as_ref() - .and_then(|sm| sm.diagnostic_events.as_ref()) - .map(|ev| ev.iter().cloned().collect::>()) + .map(|sm| sm.diagnostic_events.iter().cloned().collect::>()) .unwrap_or_default(); let all_contract_events = soroban_meta .as_ref() - .and_then(|sm| sm.events.as_ref()) - .map(|ev| ev.iter().cloned().collect::>()) + .map(|sm| sm.events.iter().cloned().collect::>()) .unwrap_or_default(); - let overall_resources = crate::decode::resource_analyzer::TransactionResultMeta::from_tx_data(tx_data); + let overall_resources = + crate::decode::resource_analyzer::TransactionResultMeta::from_tx_data(tx_data); let overall_resource_summary = crate::types::report::ResourceSummary { cpu_instructions_used: overall_resources.resources_consumed.cpu_instructions, cpu_instructions_limit: overall_resources.resources_allocated.cpu_instructions, @@ -118,25 +127,33 @@ impl MultiOpDecoder { let operation_results = decode_operation_results(&envelope, &op_results, num_ops); - let operation_event_partitions = partition_events_by_operation( - &all_diagnostic_events, - num_ops, - ); + let operation_event_partitions = + partition_events_by_operation(&all_diagnostic_events, num_ops); - let operation_contract_partitions = partition_contract_events_by_operation( - &all_contract_events, - num_ops, - ); + let operation_contract_partitions = + partition_contract_events_by_operation(&all_contract_events, num_ops); let mut reports = Vec::new(); for i in 0..num_ops { let op_info = &operation_results[i]; - let op_events = operation_event_partitions.get(i).cloned().unwrap_or_default(); - let op_contract_events = operation_contract_partitions.get(i).cloned().unwrap_or_default(); - - let error_category = op_info.error_category.clone().unwrap_or_else(|| "unknown".to_string()); - let error_name = op_info.error_name.clone().unwrap_or_else(|| "Unknown".to_string()); + let op_events = operation_event_partitions + .get(i) + .cloned() + .unwrap_or_default(); + let op_contract_events = operation_contract_partitions + .get(i) + .cloned() + .unwrap_or_default(); + + let error_category = op_info + .error_category + .clone() + .unwrap_or_else(|| "unknown".to_string()); + let error_name = op_info + .error_name + .clone() + .unwrap_or_else(|| "Unknown".to_string()); let mut report = if op_info.is_success { DiagnosticReport::new( @@ -166,12 +183,15 @@ impl MultiOpDecoder { return_value: op_info.return_value.clone(), fee: overall_fee.clone(), resources: overall_resource_summary.clone(), + operation_index: Some(i), + operation_count: Some(num_ops), }); if !op_events.is_empty() { - let op_events_xdr: Vec = op_events.iter().filter_map(|e| { - XdrCodec::to_xdr_base64(e).ok() - }).collect(); + let op_events_xdr: Vec = op_events + .iter() + .filter_map(|e| XdrCodec::to_xdr_base64(e).ok()) + .collect(); let enriched = serde_json::json!({ "diagnosticEventsXdr": op_events_xdr, @@ -203,15 +223,11 @@ impl MultiOpDecoder { report.cross_contract_attribution = if !op_contract_events.is_empty() { Some(crate::types::report::FailureAttribution { - contract_address: op_contract_events.iter().filter_map(|e| { - if let ContractEventBody::V0(v0) = &e.body { - v0.contract_id.as_ref().map(|h| { - crate::xdr::codec::XdrCodec::to_xdr_base64(h).unwrap_or_default() - }) - } else { - None - } - }).next().unwrap_or_default(), + contract_address: op_contract_events + .iter() + .filter_map(|e| e.contract_id.as_ref().map(|h| hex::encode(&h.0))) + .next() + .unwrap_or_default(), function_name: op_info.function_name.clone(), call_depth: 0, origin_description: format!("Operation {}", i + 1), @@ -237,29 +253,38 @@ fn decode_operation_results( for i in 0..num_ops { let op = get_operation(envelope, i); let op_result = op_results.get(i).cloned().unwrap_or_else(|| { - OperationResult { - ext: stellar_xdr::curr::ExtensionPoint::V0, - tr: OperationResultTr::InvokeHostFunction( - stellar_xdr::curr::InvokeHostFunctionResult::Success( - stellar_xdr::curr::Hash([0; 32]), - ), - ), - } + OperationResult::OpInner(OperationResultTr::InvokeHostFunction( + stellar_xdr::curr::InvokeHostFunctionResult::Success(stellar_xdr::curr::Hash( + [0; 32], + )), + )) }); - let info = match &op_result.tr { - OperationResultTr::InvokeHostFunction(inv_result) => { - match inv_result { + let info = match &op_result { + OperationResult::OpInner(tr) => match tr { + OperationResultTr::InvokeHostFunction(inv_result) => match inv_result { stellar_xdr::curr::InvokeHostFunctionResult::Success(hash) => { - let (fname, args, ret_val) = op.as_ref().and_then(|o| { - if let OperationBody::InvokeHostFunction(invoke) = &o.body { - let fname = invoke.function_name.to_string(); - let args: Vec = invoke.args.iter().map(|a| format!("{a:?}")).collect(); - (Some(fname), args, None) - } else { - (None, vec![], None) - } - }).unwrap_or((None, vec![], None)); + let (fname, args, ret_val) = op + .as_ref() + .and_then(|o| { + if let OperationBody::InvokeHostFunction(invoke) = &o.body { + match &invoke.host_function { + stellar_xdr::curr::HostFunction::InvokeContract(args) => { + let fname = args.function_name.to_string(); + let arguments = args + .args + .iter() + .map(|a| format!("{a:?}")) + .collect(); + Some((Some(fname), arguments, None)) + } + _ => Some((None, vec![], None)), + } + } else { + None + } + }) + .unwrap_or((None, vec![], None)); OperationResultInfo { function_name: fname, @@ -271,13 +296,18 @@ fn decode_operation_results( } } stellar_xdr::curr::InvokeHostFunctionResult::Trapped => { - let (fname, _) = op.as_ref().and_then(|o| { + let fname = op.as_ref().and_then(|o| { if let OperationBody::InvokeHostFunction(invoke) = &o.body { - (Some(invoke.function_name.to_string()), ()) + match &invoke.host_function { + stellar_xdr::curr::HostFunction::InvokeContract(args) => { + Some(args.function_name.to_string()) + } + _ => None, + } } else { - (None, ()) + None } - }).unwrap_or((None, ())); + }); OperationResultInfo { function_name: fname, @@ -285,17 +315,22 @@ fn decode_operation_results( return_value: None, is_success: false, error_category: Some("Contract".to_string()), - error_name: Some("HostError"), + error_name: Some("HostError".to_string()), } } stellar_xdr::curr::InvokeHostFunctionResult::ResourceLimitExceeded => { - let (fname, _) = op.as_ref().and_then(|o| { + let fname = op.as_ref().and_then(|o| { if let OperationBody::InvokeHostFunction(invoke) = &o.body { - (Some(invoke.function_name.to_string()), ()) + match &invoke.host_function { + stellar_xdr::curr::HostFunction::InvokeContract(args) => { + Some(args.function_name.to_string()) + } + _ => None, + } } else { - (None, ()) + None } - }).unwrap_or((None, ())); + }); OperationResultInfo { function_name: fname, @@ -303,17 +338,22 @@ fn decode_operation_results( return_value: None, is_success: false, error_category: Some("Budget".to_string()), - error_name: Some("HostError"), + error_name: Some("HostError".to_string()), } } stellar_xdr::curr::InvokeHostFunctionResult::EntryArchived => { - let (fname, _) = op.as_ref().and_then(|o| { + let fname = op.as_ref().and_then(|o| { if let OperationBody::InvokeHostFunction(invoke) = &o.body { - (Some(invoke.function_name.to_string()), ()) + match &invoke.host_function { + stellar_xdr::curr::HostFunction::InvokeContract(args) => { + Some(args.function_name.to_string()) + } + _ => None, + } } else { - (None, ()) + None } - }).unwrap_or((None, ())); + }); OperationResultInfo { function_name: fname, @@ -321,18 +361,23 @@ fn decode_operation_results( return_value: None, is_success: false, error_category: Some("Storage".to_string()), - error_name: Some("HostError"), + error_name: Some("HostError".to_string()), } } stellar_xdr::curr::InvokeHostFunctionResult::Malformed | stellar_xdr::curr::InvokeHostFunctionResult::InsufficientRefundableFee => { - let (fname, _) = op.as_ref().and_then(|o| { + let fname = op.as_ref().and_then(|o| { if let OperationBody::InvokeHostFunction(invoke) = &o.body { - (Some(invoke.function_name.to_string()), ()) + match &invoke.host_function { + stellar_xdr::curr::HostFunction::InvokeContract(args) => { + Some(args.function_name.to_string()) + } + _ => None, + } } else { - (None, ()) + None } - }).unwrap_or((None, ())); + }); OperationResultInfo { function_name: fname, @@ -340,19 +385,53 @@ fn decode_operation_results( return_value: None, is_success: false, error_category: Some("Context".to_string()), - error_name: Some("HostError"), + error_name: Some("HostError".to_string()), } } + }, + _ => { + let fname = op + .as_ref() + .and_then(|o| { + if let OperationBody::InvokeHostFunction(invoke) = &o.body { + match &invoke.host_function { + stellar_xdr::curr::HostFunction::InvokeContract(args) => { + Some(args.function_name.to_string()) + } + _ => None, + } + } else { + None + } + }) + .unwrap_or_default(); + + OperationResultInfo { + function_name: if fname.is_empty() { None } else { Some(fname) }, + arguments: vec![], + return_value: None, + is_success: false, + error_category: Some("Unknown".to_string()), + error_name: Some("NonInvokeHostFunctionOperation".to_string()), + } } - } + }, _ => { - let fname = op.as_ref().and_then(|o| { - if let OperationBody::InvokeHostFunction(invoke) = &o.body { - Some(invoke.function_name.to_string()) - } else { - None - } - }).unwrap_or_default(); + let fname = op + .as_ref() + .and_then(|o| { + if let OperationBody::InvokeHostFunction(invoke) = &o.body { + match &invoke.host_function { + stellar_xdr::curr::HostFunction::InvokeContract(args) => { + Some(args.function_name.to_string()) + } + _ => None, + } + } else { + None + } + }) + .unwrap_or_default(); OperationResultInfo { function_name: if fname.is_empty() { None } else { Some(fname) }, @@ -360,7 +439,7 @@ fn decode_operation_results( return_value: None, is_success: false, error_category: Some("Unknown".to_string()), - error_name: Some("NonInvokeHostFunctionOperation"), + error_name: Some("NonInvokeHostFunctionOperation".to_string()), } } }; @@ -373,9 +452,13 @@ fn decode_operation_results( fn get_operation(envelope: &TransactionEnvelope, index: usize) -> Option { match envelope { - TransactionEnvelope::Tx(TxV1Envelope { tx, .. }) => tx.operations.get(index).cloned(), + TransactionEnvelope::Tx(TransactionV1Envelope { tx, .. }) => { + tx.operations.get(index).cloned() + } TransactionEnvelope::TxFeeBump(fb) => match &fb.tx.inner_tx { - FeeBumpTransactionInnerTx::Tx(TxV1Envelope { tx, .. }) => tx.operations.get(index).cloned(), + FeeBumpTransactionInnerTx::Tx(TransactionV1Envelope { tx, .. }) => { + tx.operations.get(index).cloned() + } }, TransactionEnvelope::TxV0(_) => None, } @@ -496,7 +579,8 @@ fn enrich_resource_report( report: &mut DiagnosticReport, tx_data: &serde_json::Value, ) -> crate::error::GratResult<()> { - crate::decode::resource_analyzer::enrich_report(report, tx_data) + crate::decode::resource_analyzer::enrich_report(report, tx_data); + Ok(()) } pub async fn decode_transaction_with_op_filter( diff --git a/crates/core/src/decode/report.rs b/crates/core/src/decode/report.rs index 7489915f..419575f6 100644 --- a/crates/core/src/decode/report.rs +++ b/crates/core/src/decode/report.rs @@ -48,6 +48,8 @@ pub fn build_report(error: &ClassifiedError) -> GratResult { failing_contract_id: None, call_chain: None, resource_diagnostics: None, + operation_index: None, + operation_count: None, learn_more: "https://developers.stellar.org/docs/learn/smart-contracts/errors" .to_string(), }; diff --git a/crates/core/src/decode/return_decoder.rs b/crates/core/src/decode/return_decoder.rs index 7aa95802..74fed631 100644 --- a/crates/core/src/decode/return_decoder.rs +++ b/crates/core/src/decode/return_decoder.rs @@ -266,7 +266,9 @@ impl ReturnValueDecoder { }); let field_value = match matching_entry { - Some(entry) => Self::decode_value(&entry.val, field.type_def.as_ref(), Some(cs)), + Some(entry) => { + Self::decode_value(&entry.val, field.type_def.as_ref(), Some(cs)) + } None => Value::Null, }; map_obj.insert(field.name.clone(), field_value); @@ -312,7 +314,11 @@ impl ReturnValueDecoder { } } - fn decode_union(val: &ScVal, union_def: &crate::spec::decoder::ContractUnionDef, cs: &ContractSpec) -> Value { + fn decode_union( + val: &ScVal, + union_def: &crate::spec::decoder::ContractUnionDef, + cs: &ContractSpec, + ) -> Value { match val { ScVal::Symbol(sym) => json!(sym.to_string()), ScVal::Vec(Some(v)) if !v.is_empty() => { @@ -343,7 +349,9 @@ impl ReturnValueDecoder { let items = &v[1..]; for (i, field) in fields.iter().enumerate() { let field_val = match items.get(i) { - Some(item) => Self::decode_value(item, field.type_def.as_ref(), Some(cs)), + Some(item) => { + Self::decode_value(item, field.type_def.as_ref(), Some(cs)) + } None => Value::Null, }; map_obj.insert(field.name.clone(), field_val); @@ -437,7 +445,7 @@ impl ReturnValueDecoder { #[cfg(test)] mod tests { use super::*; - use crate::spec::decoder::{ContractStructField, ContractStructDef}; + use crate::spec::decoder::{ContractStructDef, ContractStructField}; use stellar_xdr::curr::{ScString, ScSymbol}; #[test] @@ -521,9 +529,12 @@ mod tests { let decoder = ReturnValueDecoder::new(); let ok_val = ScVal::Vec(Some( - vec![ScVal::Symbol(ScSymbol("Ok".try_into().unwrap())), ScVal::U32(200)] - .try_into() - .unwrap(), + vec![ + ScVal::Symbol(ScSymbol("Ok".try_into().unwrap())), + ScVal::U32(200), + ] + .try_into() + .unwrap(), )); let res_spec = ScSpecTypeDef::Result(Box::new(stellar_xdr::curr::ScSpecTypeResult { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 706d8844..84300347 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,16 +1,26 @@ +#[cfg(feature = "decode")] pub mod archive; +#[cfg(feature = "decode")] pub mod cache; +#[cfg(feature = "decode")] pub mod debugger; +#[cfg(feature = "decode")] pub mod decode; pub mod error; +#[cfg(feature = "decode")] pub mod network; +#[cfg(feature = "decode")] pub mod replay; +#[cfg(feature = "decode")] pub mod rpc; +#[cfg(feature = "decode")] pub mod spec; pub mod taxonomy; +#[cfg(feature = "decode")] pub mod types; pub mod xdr; +#[cfg(feature = "decode")] pub use decode::{ walk_diagnostic_events, AddressCredential, AddressWithNonce, ArgumentDecoder, AuthChain, AuthCredential, AuthFunctionKind, AuthInvocation, DecodedArgument, DecodedFunctionCall, @@ -18,9 +28,13 @@ pub use decode::{ ReturnValueDecoder, StructuredDiagnosticEvent, }; pub use error::{GratError, GratResult}; +#[cfg(feature = "decode")] pub use network::config::Network; +#[cfg(feature = "decode")] pub use types::address::Address; +#[cfg(feature = "decode")] pub use types::config::NetworkConfig; +#[cfg(feature = "decode")] pub use types::report::DiagnosticReport; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/core/src/rpc/client.rs b/crates/core/src/rpc/client.rs index c409ae5e..27db0300 100644 --- a/crates/core/src/rpc/client.rs +++ b/crates/core/src/rpc/client.rs @@ -742,30 +742,10 @@ mod tests { #[tokio::test] async fn test_get_ledger_entries_empty_response() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let rpc_url = format!("http://{addr}"); - - let config = NetworkConfig { - network: crate::network::Network::Testnet, - rpc_url, - network_passphrase: "test".to_string(), - archive_urls: vec![], - api_key: None, - request_timeout_secs: 30, - }; - let client = SorobanRpcClient::new(&config); - - tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let body = r#"{"jsonrpc":"2.0","id":1,"result":{"latestLedger":123,"entries":[]}}"#; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", - body.len(), - body - ); - socket.write_all(response.as_bytes()).await.unwrap(); - }); + let body = r#"{"jsonrpc":"2.0","id":1,"result":{"latestLedger":123,"entries":[]}}"#; + let responses = vec![http_response(200, "OK", body)]; + let addr = spawn_mock_server(responses).await; + let client = make_client(addr); let result = client .get_ledger_entries(&["key1".to_string()]) @@ -810,33 +790,10 @@ mod tests { #[tokio::test] async fn test_get_transaction_mocked_response() { - use tokio::io::AsyncWriteExt; - use tokio::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let rpc_url = format!("http://{addr}"); - - let config = NetworkConfig { - network: crate::network::Network::Testnet, - rpc_url, - network_passphrase: "test".to_string(), - archive_urls: vec![], - api_key: None, - request_timeout_secs: 30, - }; - let client = SorobanRpcClient::new(&config); - - tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let body = r#"{"jsonrpc":"2.0","id":1,"result":{"status":"SUCCESS","latestLedger":123,"latestLedgerCloseTime":1711620000,"ledger":120}}"#; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", - body.len(), - body - ); - socket.write_all(response.as_bytes()).await.unwrap(); - }); + let body = r#"{"jsonrpc":"2.0","id":1,"result":{"status":"SUCCESS","latestLedger":123,"latestLedgerCloseTime":1711620000,"ledger":120}}"#; + let responses = vec![http_response(200, "OK", body)]; + let addr = spawn_mock_server(responses).await; + let client = make_client(addr); let result = client.get_transaction("hash123").await.unwrap(); assert_eq!(result.status, TransactionStatus::Success); @@ -926,31 +883,11 @@ mod tests { #[tokio::test] async fn test_simulate_transaction_returns_rpc_error_on_failure() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let rpc_url = format!("http://{addr}"); - - let config = NetworkConfig { - network: crate::network::Network::Testnet, - rpc_url, - network_passphrase: "test".to_string(), - archive_urls: vec![], - api_key: None, - request_timeout_secs: 30, - }; - let client = SorobanRpcClient::new(&config); - - tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let body = - r#"{"jsonrpc":"2.0","id":1,"result":{"latestLedger":100,"error":"contract trap"}}"#; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", - body.len(), - body - ); - socket.write_all(response.as_bytes()).await.unwrap(); - }); + let body = + r#"{"jsonrpc":"2.0","id":1,"result":{"latestLedger":100,"error":"contract trap"}}"#; + let responses = vec![http_response(200, "OK", body)]; + let addr = spawn_mock_server(responses).await; + let client = make_client(addr); let result = client.simulate_transaction("AAAA").await; assert!(result.is_err()); diff --git a/crates/core/src/spec/decoder.rs b/crates/core/src/spec/decoder.rs index d4db14b9..16b9c83e 100644 --- a/crates/core/src/spec/decoder.rs +++ b/crates/core/src/spec/decoder.rs @@ -1,8 +1,6 @@ use crate::error::{GratError, GratResult}; use serde::{Deserialize, Serialize}; -use stellar_xdr::curr::{ - Limited, Limits, ReadXdr, ScSpecEntry, ScSpecTypeDef, ScSpecUdtStructV0, -}; +use stellar_xdr::curr::{Limited, Limits, ReadXdr, ScSpecEntry, ScSpecTypeDef, ScSpecUdtStructV0}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContractErrorEntry { @@ -559,8 +557,8 @@ mod tests { fn test_extract_raw_section_custom_name() { let section_data = vec![10, 20, 30]; let wasm = build_wasm_with_custom_section("contractenvmetav0", §ion_data); - let result = - SpecParser::extract_raw_section(&wasm, "contractenvmetav0").expect("Should find section"); + let result = SpecParser::extract_raw_section(&wasm, "contractenvmetav0") + .expect("Should find section"); assert_eq!(result, section_data); } @@ -613,7 +611,7 @@ mod tests { assert_eq!(result[0].doc.as_deref(), Some("A user balance")); assert_eq!(result[0].fields.len(), 2); assert_eq!(result[0].fields[0].name, "amount"); - assert_eq!(result[0].fields[0].type_name, "i128"); + assert_eq!(result[0].fields[0].type_name, "I128"); assert_eq!(result[0].fields[1].name, "asset"); assert_eq!(result[0].fields[1].type_name, "Symbol"); } @@ -633,11 +631,7 @@ mod tests { #[test] fn test_extract_raw_structs_returns_sc_spec_udt_struct_v0() { - let entry = make_struct_spec_entry( - "Voter", - "", - vec![("name", "", ScSpecTypeDef::String)], - ); + let entry = make_struct_spec_entry("Voter", "", vec![("name", "", ScSpecTypeDef::String)]); let wasm = make_wasm_with_structs(vec![entry]); let result = SpecParser::extract_raw_structs(&wasm).expect("Should extract raw structs"); assert_eq!(result.len(), 1); diff --git a/crates/core/src/spec/tests/wasm_tests.rs b/crates/core/src/spec/tests/wasm_tests.rs index bd330c8c..8224342f 100644 --- a/crates/core/src/spec/tests/wasm_tests.rs +++ b/crates/core/src/spec/tests/wasm_tests.rs @@ -1,7 +1,7 @@ use crate::spec::decoder::{decode_contract_spec, ContractStructDef}; use stellar_xdr::curr::{ - Limits, ScSpecEntry, ScSpecTypeDef, ScSpecTypeUdt, ScSpecTypeVec, - ScSpecUdtStructFieldV0, ScSpecUdtStructV0, WriteXdr, + Limits, ScSpecEntry, ScSpecTypeDef, ScSpecTypeUdt, ScSpecTypeVec, ScSpecUdtStructFieldV0, + ScSpecUdtStructV0, WriteXdr, }; fn leb128_encode(mut value: u64) -> Vec { @@ -204,15 +204,13 @@ fn test_vec_of_nested_struct_type_def() { .expect("items type_def should be present"); match type_def { - ScSpecTypeDef::Vec(vec) => { - match &*vec.element_type { - ScSpecTypeDef::Udt(udt) => { - let name: String = udt.name.to_string(); - assert_eq!(name, "Inner"); - } - other => panic!("expected Vec element to be Udt, got {other:?}"), + ScSpecTypeDef::Vec(vec) => match &*vec.element_type { + ScSpecTypeDef::Udt(udt) => { + let name: String = udt.name.to_string(); + assert_eq!(name, "Inner"); } - } + other => panic!("expected Vec element to be Udt, got {other:?}"), + }, other => panic!("expected Vec variant, got {other:?}"), } } diff --git a/crates/core/src/taxonomy/data/auth.toml b/crates/core/src/taxonomy/data/auth.toml index 5755e4af..a47cd212 100644 --- a/crates/core/src/taxonomy/data/auth.toml +++ b/crates/core/src/taxonomy/data/auth.toml @@ -19,6 +19,8 @@ Soroban contracts can require authorization for specific operations. When the au provided with the transaction do not match the authorization requirements of the contract invocation, the host cannot validate the action and raises this error. """ +related_errors = ["host.auth.not_authorized"] +source_file = "soroban-env-host/src/auth.rs" [[errors.common_causes]] description = "Missing or incorrect auth entries in the transaction envelope" @@ -38,8 +40,6 @@ description = "Verify that all required signers are included and signing the cor difficulty = "medium" requires_upgrade = false -related_errors = ["host.auth.not_authorized"] -source_file = "soroban-env-host/src/auth.rs" [[errors]] id = "host.auth.invalid_signature" @@ -56,6 +56,8 @@ signing key was used, or the auth entry expired. For smart-wallet or contract-ac it means the wallet's __check_auth logic rejected the payload or could not authenticate the request it was asked to approve. """ +related_errors = ["host.auth.missing_auth", "host.auth.forbidden"] +source_file = "soroban-env-host/src/builtin_contracts/account_contract.rs" [[errors.common_causes]] description = "The payload was signed by the wrong Ed25519 key" @@ -79,8 +81,6 @@ description = "Confirm the account key or wallet logic is signing the exact auth difficulty = "medium" requires_upgrade = false -related_errors = ["host.auth.missing_auth", "host.auth.forbidden"] -source_file = "soroban-env-host/src/builtin_contracts/account_contract.rs" [[errors]] id = "host.auth.missing_auth" @@ -95,6 +95,8 @@ The contract or host expected an authorization record for a required signer, but did not include one. This often happens when the client skips simulation, rebuilds the operation without preserving sorobanData, or omits one of the signers needed by a multi-party flow. """ +related_errors = ["host.auth.invalid_action"] +source_file = "soroban-env-host/src/auth.rs" [[errors.common_causes]] description = "The transaction was rebuilt without the required sorobanData" @@ -114,8 +116,6 @@ description = "Check that every required signer is present before submitting" difficulty = "medium" requires_upgrade = false -related_errors = ["host.auth.invalid_action"] -source_file = "soroban-env-host/src/auth.rs" [[errors]] id = "host.auth.forbidden" @@ -131,6 +131,8 @@ permissions granted to the signer, wallet, or delegated auth chain. This commonl a contract tries to escalate privileges, invoke a protected branch, or use a signer that is not permitted to approve the current action. """ +related_errors = ["host.auth.not_authorized"] +source_file = "soroban-env-host/src/builtin_contracts/account_contract.rs" [[errors.common_causes]] description = "The auth tree does not include the role required for the current call" @@ -150,8 +152,6 @@ description = "Rebuild the auth tree so the approved signer covers the exact sub difficulty = "medium" requires_upgrade = false -related_errors = ["host.auth.not_authorized"] -source_file = "soroban-env-host/src/builtin_contracts/account_contract.rs" [[errors]] id = "host.auth.expired_auth" @@ -166,6 +166,8 @@ Auth entries are only valid up to their signature expiration ledger. If the tran submitted after that ledger, the host rejects the authorization even if the signature itself was otherwise correct. """ +related_errors = ["host.auth.invalid_signature"] +source_file = "soroban-env-host/src/auth.rs" [[errors.common_causes]] description = "The signature expiration ledger was set too low for the submission delay" @@ -185,8 +187,6 @@ description = "Increase the signature expiration window only as much as the work difficulty = "medium" requires_upgrade = false -related_errors = ["host.auth.invalid_signature"] -source_file = "soroban-env-host/src/auth.rs" [[errors]] id = "host.auth.not_authorized" @@ -202,6 +202,8 @@ tree still does not cover the exact invocation or sub-invocation being executed. when a contract call is legitimate in principle but the specific branch needs a stronger signer, another delegated approval, or a different permission scope. """ +related_errors = ["host.auth.forbidden"] +source_file = "soroban-env-host/src/auth.rs" [[errors.common_causes]] description = "The caller authenticated successfully but lacked permission for the specific branch" @@ -221,5 +223,3 @@ description = "Verify any delegated or policy signer actually authorizes the cur difficulty = "medium" requires_upgrade = false -related_errors = ["host.auth.forbidden"] -source_file = "soroban-env-host/src/auth.rs" diff --git a/crates/core/src/taxonomy/data/budget.toml b/crates/core/src/taxonomy/data/budget.toml index 477c5af9..24686981 100644 --- a/crates/core/src/taxonomy/data/budget.toml +++ b/crates/core/src/taxonomy/data/budget.toml @@ -23,6 +23,9 @@ This commonly occurs in computationally intensive operations such as large loops cryptographic operations, or deeply nested cross-contract calls where each sub-invocation \ consumes a portion of the parent's budget. """ +related_errors = ["host.budget.insufficient_memory"] +source_file = "soroban-env-host/src/budget.rs" +documentation_url = "https://soroban.stellar.org/docs/fundamentals/fees-and-metering" [[errors.common_causes]] description = "Unbounded or large loops processing collections (vectors, maps) with many entries" @@ -51,9 +54,6 @@ description = "Break the operation into multiple smaller transactions" difficulty = "hard" requires_upgrade = true -related_errors = ["host.budget.limit_exceeded.memory"] -source_file = "soroban-env-host/src/budget.rs" -documentation_url = "https://soroban.stellar.org/docs/fundamentals/fees-and-metering" [[errors]] id = "host.budget.insufficient_instructions" @@ -175,7 +175,8 @@ This is a diagnostic signal, not a failure. The execution consumed a high fracti allocated budget but did not exceed it. Use this to identify contracts that are close to the \ limit and may fail under slightly heavier load. """ - +related_errors = ["host.budget.limit_exceeded.cpu"] +source_file = "soroban-env-host/src/budget.rs" [[errors.common_causes]] description = "Contract logic that scales with input size, approaching the declared limit" likelihood = "medium" @@ -190,5 +191,3 @@ description = "Add resource metering logs to your CI pipeline to catch contracts difficulty = "medium" requires_upgrade = false -related_errors = ["host.budget.limit_exceeded.cpu"] -source_file = "soroban-env-host/src/budget.rs" \ No newline at end of file diff --git a/crates/core/src/taxonomy/data/context.toml b/crates/core/src/taxonomy/data/context.toml index 3d4659fc..3a402be0 100644 --- a/crates/core/src/taxonomy/data/context.toml +++ b/crates/core/src/taxonomy/data/context.toml @@ -53,6 +53,8 @@ execution context, such as using a host capability before the host is ready or o allowed invocation lifecycle. This usually points to contract or caller behavior, not a broken \ network environment. """ +related_errors = [] +source_file = "soroban-env-host/src/host.rs" [[errors.common_causes]] description = "Contract code or a host integration called an environment action outside its valid lifecycle" @@ -63,8 +65,6 @@ description = "Review where the host function is called and move it into a valid difficulty = "medium" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/host.rs" [[errors]] id = "host.context.internal_error" @@ -78,6 +78,8 @@ detailed_explanation = """ Internal errors indicate a bug or unexpected state in the Soroban host itself, not in the \ contract code. These are rare, and should be reported to the Stellar/Soroban team. """ +related_errors = [] +source_file = "soroban-env-host/src/host.rs" [[errors.common_causes]] description = "Bug in the Soroban host runtime" @@ -88,5 +90,3 @@ description = "Report the issue to the Stellar/Soroban GitHub repository with th difficulty = "easy" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/host.rs" diff --git a/crates/core/src/taxonomy/data/contract.toml b/crates/core/src/taxonomy/data/contract.toml index be653aa7..8b4dc8b9 100644 --- a/crates/core/src/taxonomy/data/contract.toml +++ b/crates/core/src/taxonomy/data/contract.toml @@ -22,6 +22,8 @@ returns an error value, the host wraps it as a ContractError with the numeric co To understand a contract error, you need the contract's specification (contractspecv0 metadata) \ which maps the numeric code to a human-readable name and optional documentation. """ +related_errors = ["host.auth.not_authorized"] +source_file = "soroban-env-host/src/host.rs" [[errors.common_causes]] description = "Business logic assertion failure in the contract (e.g., insufficient balance, invalid state)" @@ -45,8 +47,6 @@ description = "Review the contract source code for the error enum definition to difficulty = "medium" requires_upgrade = false -related_errors = ["host.auth.not_authorized"] -source_file = "soroban-env-host/src/host.rs" [[errors]] @@ -61,6 +61,8 @@ detailed_explanation = """ The contract encountered an internal error during execution, indicating a protocol \ implementation issue or invalid ledger state. """ +related_errors = [] +source_file = "soroban-env-host/src/host.rs" [[errors.common_causes]] description = "Internal validation or state consistency checks failed in the contract" @@ -76,8 +78,6 @@ description = "Compare host and SDK versions between simulation and submission t difficulty = "medium" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/host.rs" [[errors]] @@ -92,6 +92,8 @@ detailed_explanation = """ The contract attempted an operation that is unsupported by the contract configuration or type \ (e.g., performing a clawback on an asset when clawback is disabled). """ +related_errors = [] +source_file = "soroban-env-host/src/host.rs" [[errors.common_causes]] description = "Invoking clawback on a non-clawbackable asset" @@ -107,8 +109,6 @@ description = "Re-check the asset's issuer-set flags (e.g. AUTH_REQUIRED, CLAWBA difficulty = "easy" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/host.rs" [[errors]] @@ -122,6 +122,8 @@ summary = "The contract instance has already been initialized and cannot be re-i detailed_explanation = """ The contract instance was initialized twice. Soroban contracts typically only allow initialization once. """ +related_errors = [] +source_file = "soroban-env-host/src/host.rs" [[errors.common_causes]] description = "Calling the initialize function on an already initialized contract instance" @@ -137,8 +139,6 @@ description = "Guard the initialize function with a storage check (e.g. instance difficulty = "easy" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/host.rs" [[errors]] @@ -152,7 +152,8 @@ summary = "An account involved in the transaction does not exist on the network. detailed_explanation = """ The operation required a specific account to exist on the network, but the account could not be found. """ - +related_errors = [] +source_file = "soroban-env-host/src/host.rs" [[errors.common_causes]] description = "Providing an invalid account address or an account that has not been created/funded" likelihood = "high" @@ -167,5 +168,3 @@ description = "Fund and create the account on the target network before invoking difficulty = "easy" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/host.rs" \ No newline at end of file diff --git a/crates/core/src/taxonomy/data/crypto.toml b/crates/core/src/taxonomy/data/crypto.toml index 383c3bd8..d3ab815f 100644 --- a/crates/core/src/taxonomy/data/crypto.toml +++ b/crates/core/src/taxonomy/data/crypto.toml @@ -19,6 +19,8 @@ Cryptographic host functions expect inputs of specific formats and lengths. For ed25519 public keys must be exactly 32 bytes, signatures must be 64 bytes, and hash inputs \ must not exceed size limits. Violating these constraints raises this error. """ +related_errors = [] +source_file = "soroban-env-host/src/host/crypto.rs" [[errors.common_causes]] description = "Public key or signature with incorrect byte length" @@ -33,5 +35,3 @@ description = "Verify that all cryptographic inputs have the correct length and difficulty = "easy" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/host/crypto.rs" diff --git a/crates/core/src/taxonomy/data/events.toml b/crates/core/src/taxonomy/data/events.toml index 69e34a7e..e02d49d4 100644 --- a/crates/core/src/taxonomy/data/events.toml +++ b/crates/core/src/taxonomy/data/events.toml @@ -20,6 +20,8 @@ execution. When this limit is exceeded, the host terminates execution with this This includes both contract-emitted events (via `env.events().publish()`) and internal \ diagnostic events. """ +related_errors = ["host.budget.limit_exceeded.cpu"] +source_file = "soroban-env-host/src/events.rs" [[errors.common_causes]] description = "Emitting too many events or events with large data payloads in a single transaction" @@ -39,5 +41,3 @@ description = "Batch operations across multiple transactions to reduce per-trans difficulty = "medium" requires_upgrade = true -related_errors = ["host.budget.limit_exceeded.cpu"] -source_file = "soroban-env-host/src/events.rs" diff --git a/crates/core/src/taxonomy/data/storage.toml b/crates/core/src/taxonomy/data/storage.toml index c101ef48..1d38a12f 100644 --- a/crates/core/src/taxonomy/data/storage.toml +++ b/crates/core/src/taxonomy/data/storage.toml @@ -19,6 +19,8 @@ Every Soroban transaction must declare its read/write footprint — the set will access. If the contract tries to read or write an entry not in the declared footprint, \ the host rejects the access. This is a fundamental part of Soroban's parallel execution model. """ +related_errors = ["host.storage.entry_not_found"] +source_file = "soroban-env-host/src/storage.rs" [[errors.common_causes]] description = "Missing ledger entries in the transaction's resource footprint declaration" @@ -38,8 +40,6 @@ description = "Manually add the missing ledger key to the transaction's read/wri difficulty = "medium" requires_upgrade = false -related_errors = ["host.storage.missing_key"] -source_file = "soroban-env-host/src/storage.rs" [[errors]] id = "host.storage.entry_not_found" @@ -159,7 +159,8 @@ contract read or written an entry that is close to expiring. The operation succe entry must be extended with extendTTL before it archives or future transactions will fail with \ EntryNotFound. This is not a blocking error — it is a signal to act before the entry is lost. """ - +related_errors = ["host.storage.entry_not_found"] +source_file = "soroban-env-host/src/storage.rs" [[errors.common_causes]] description = "Contract data was created or last extended many ledgers ago and TTL refresh was missed" likelihood = "high" @@ -174,5 +175,3 @@ description = "Set up automated TTL extension for long-lived contract data" difficulty = "medium" requires_upgrade = true -related_errors = ["host.storage.entry_not_found"] -source_file = "soroban-env-host/src/storage.rs" \ No newline at end of file diff --git a/crates/core/src/taxonomy/data/wasm.toml b/crates/core/src/taxonomy/data/wasm.toml index 22df8cce..31ca46ad 100644 --- a/crates/core/src/taxonomy/data/wasm.toml +++ b/crates/core/src/taxonomy/data/wasm.toml @@ -19,6 +19,8 @@ Before executing a contract, the Soroban host validates the WASM bytecode. If th fails validation (e.g., uses disallowed WASM instructions, imports unsupported host functions, \ or has malformed sections), the host rejects it with this error. """ +related_errors = [] +source_file = "soroban-env-host/src/vm.rs" [[errors.common_causes]] description = "Contract compiled with an incompatible Soroban SDK version" @@ -33,8 +35,6 @@ description = "Recompile the contract with the latest compatible Soroban SDK ver difficulty = "easy" requires_upgrade = true -related_errors = [] -source_file = "soroban-env-host/src/vm.rs" [[errors]] id = "host.wasm.unreachable" @@ -50,6 +50,8 @@ almost always the result of a Rust `panic!`, a failed `assert!`/`unwrap()`/`expe explicitly unreachable branch the compiler emitted. Execution halts immediately and the whole \ invocation is rolled back. """ +related_errors = [] +source_file = "soroban-env-host/src/vm.rs" [[errors.common_causes]] description = "A `panic!`, `unwrap()`, or `expect()` on a `None`/`Err` value inside the contract" likelihood = "high" @@ -64,8 +66,6 @@ requires_upgrade = false description = "Inspect the diagnostic events for the panic message to locate the failing assertion" difficulty = "easy" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/vm.rs" [[errors]] id = "host.wasm.memory_access_out_of_bounds" @@ -80,6 +80,8 @@ The contract attempted to load or store at a linear-memory address outside the b allocated memory. The WebAssembly VM detects this and traps to prevent invalid memory access. \ This usually indicates a bug in low-level pointer or slice handling rather than ordinary contract logic. """ +related_errors = [] +source_file = "soroban-env-host/src/vm.rs" [[errors.common_causes]] description = "Manual pointer arithmetic or `unsafe` slice handling that runs past the end of a buffer" likelihood = "high" @@ -94,8 +96,6 @@ requires_upgrade = false description = "Rebuild the contract with a known-good Soroban SDK and toolchain version" difficulty = "easy" requires_upgrade = true -related_errors = [] -source_file = "soroban-env-host/src/vm.rs" [[errors]] id = "host.wasm.table_access_out_of_bounds" @@ -110,6 +110,8 @@ WebAssembly resolves indirect (dynamic) calls through a function table. The cont table index that does not exist, so the VM trapped. This typically points to a corrupted module or \ a toolchain bug rather than ordinary contract logic. """ +related_errors = [] +source_file = "soroban-env-host/src/vm.rs" [[errors.common_causes]] description = "A miscompiled or corrupted WASM module with an invalid function-table layout" likelihood = "medium" @@ -120,8 +122,6 @@ likelihood = "low" description = "Recompile the contract from clean sources with a supported Soroban SDK version" difficulty = "easy" requires_upgrade = true -related_errors = [] -source_file = "soroban-env-host/src/vm.rs" [[errors]] id = "host.wasm.indirect_call_type_mismatch" @@ -136,6 +136,8 @@ An indirect call resolved to a function whose type signature differs from the on call site. WebAssembly enforces signature equality for indirect calls and traps on mismatch. This \ almost always indicates a corrupted module or a toolchain defect. """ +related_errors = [] +source_file = "soroban-env-host/src/vm.rs" [[errors.common_causes]] description = "A corrupted or miscompiled WASM module with mismatched function-type metadata" likelihood = "medium" @@ -146,8 +148,6 @@ likelihood = "low" description = "Rebuild the contract from clean sources with a single, supported toolchain" difficulty = "easy" requires_upgrade = true -related_errors = [] -source_file = "soroban-env-host/src/vm.rs" [[errors]] id = "host.wasm.integer_division_by_zero" @@ -162,6 +162,8 @@ The contract executed an integer division (`/`) or remainder (`%`) where the div zero. WebAssembly traps on integer division by zero rather than returning a value, halting the \ invocation. """ +related_errors = [] +source_file = "soroban-env-host/src/vm.rs" [[errors.common_causes]] description = "Dividing by a quantity, balance, or supply value that can be zero" likelihood = "high" @@ -176,8 +178,6 @@ requires_upgrade = false description = "Use checked arithmetic (`checked_div`) and handle the `None` case explicitly" difficulty = "easy" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/vm.rs" [[errors]] id = "host.wasm.integer_overflow" @@ -192,6 +192,8 @@ An arithmetic operation produced a result outside the range of its integer type debug builds Rust inserts overflow checks that surface here; common sources are token-amount math \ and accumulators that exceed their type's maximum. """ +related_errors = [] +source_file = "soroban-env-host/src/vm.rs" [[errors.common_causes]] description = "Adding or multiplying token amounts that exceed the integer type's maximum" likelihood = "high" @@ -206,8 +208,6 @@ requires_upgrade = false description = "Use a wider integer type (e.g. `i128`/`u128`) for accumulators and amount math" difficulty = "medium" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/vm.rs" [[errors]] id = "host.wasm.invalid_conversion_to_int" @@ -223,6 +223,8 @@ outside the representable range of the target integer type. WebAssembly traps on conversions. Floating-point math is uncommon in Soroban contracts, so this usually stems from \ imported library code. """ +related_errors = [] +source_file = "soroban-env-host/src/vm.rs" [[errors.common_causes]] description = "Converting a NaN or out-of-range floating-point value to an integer" likelihood = "medium" @@ -233,8 +235,6 @@ likelihood = "low" description = "Avoid floating-point arithmetic in contracts; use integer or fixed-point math instead" difficulty = "medium" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/vm.rs" [[errors]] id = "host.wasm.stack_overflow" @@ -249,6 +249,8 @@ The contract's call depth grew beyond the WebAssembly VM's stack limit and trapp by unbounded or very deep recursion, or by large stack-allocated values pushing nested calls past \ the limit. """ +related_errors = [] +source_file = "soroban-env-host/src/vm.rs" [[errors.common_causes]] description = "Unbounded or missing-base-case recursion in contract logic" likelihood = "high" @@ -259,5 +261,3 @@ likelihood = "medium" description = "Add a base case or depth limit to recursive functions, or rewrite them iteratively" difficulty = "medium" requires_upgrade = false -related_errors = [] -source_file = "soroban-env-host/src/vm.rs" diff --git a/crates/core/src/taxonomy/linter.rs b/crates/core/src/taxonomy/linter.rs index 7f961ce8..311153fb 100644 --- a/crates/core/src/taxonomy/linter.rs +++ b/crates/core/src/taxonomy/linter.rs @@ -1,30 +1,54 @@ use crate::error::{GratError, GratResult}; -use crate::taxonomy::loader::TaxonomyParser; -use crate::taxonomy::schema::ErrorCategory; +use crate::taxonomy::schema::{ErrorCategory, TaxonomyEntry, TaxonomySchema}; use std::collections::{HashMap, HashSet}; use std::path::Path; -/// A single issue found by the linter. -#[derive(Debug, Clone)] -pub struct LintIssue { - /// Source file where the issue was found. - pub file: String, - /// The `id` of the taxonomy entry the issue relates to, if applicable. - pub entry_id: Option, - /// Human-readable description of the issue. - pub message: String, +const MIN_DESCRIPTION_LEN: usize = 15; + +/// Helper to locate the 1-based line number of a field in a TOML file. +fn get_line_number(toml_content: &str, entry_id: &str, field_name: Option<&str>) -> Option { + let mut entry_line_idx = None; + let lines: Vec<&str> = toml_content.lines().collect(); + for (idx, line) in lines.iter().enumerate() { + let cleaned = line.replace(' ', "").replace('\'', "\""); + if cleaned.contains(&format!("id=\"{}\"", entry_id)) { + entry_line_idx = Some(idx); + break; + } + } + + let entry_idx = entry_line_idx?; + + if let Some(field) = field_name { + for idx in entry_idx..lines.len() { + if idx > entry_idx + && (lines[idx].trim().starts_with("[[errors]]") + || lines[idx].trim().starts_with("[metadata]") + || lines[idx].trim().starts_with("[category]")) + { + break; + } + let cleaned = lines[idx].replace(' ', ""); + if cleaned.starts_with(&format!("{}=", field)) { + return Some(idx + 1); + } + // For nested descriptions (causes or fixes) + if field == "description" && lines[idx].trim().starts_with("description =") { + return Some(idx + 1); + } + } + } + + Some(entry_idx + 1) } /// Lint all `*.toml` taxonomy files in `dir`. /// -/// Returns a list of [`LintIssue`]s. The function does **not** short-circuit on -/// the first error – it processes every file and collects all issues. -pub fn lint_dir(dir: &Path) -> GratResult> { - let mut issues: Vec = Vec::new(); - - // ------------------------------------------------------------------ +/// Under the strict build-time validation rules, this function will panic +/// immediately if any syntax error, unknown field, duplicate code, or invalid/too-short +/// property is encountered. +pub fn lint_dir(dir: &Path) -> GratResult<()> { // 1. Gather all *.toml files in the directory - // ------------------------------------------------------------------ let mut toml_files: Vec = Vec::new(); let dir_reader = std::fs::read_dir(dir) .map_err(|e| GratError::TaxonomyError(format!("Cannot read taxonomy dir: {e}")))?; @@ -37,12 +61,10 @@ pub fn lint_dir(dir: &Path) -> GratResult> { } } - // All successfully-parsed entries, annotated with their source file name. - let mut all_entries: Vec<(String, crate::taxonomy::schema::TaxonomyEntry)> = Vec::new(); + // All successfully-parsed entries, annotated with their source file name and file content. + let mut all_entries: Vec<(String, String, TaxonomyEntry)> = Vec::new(); - // ------------------------------------------------------------------ // 2. Parse every file and validate per-entry rules - // ------------------------------------------------------------------ for path in &toml_files { let file_name = path .file_name() @@ -50,175 +72,204 @@ pub fn lint_dir(dir: &Path) -> GratResult> { .unwrap_or("unknown") .to_string(); - let content = match std::fs::read_to_string(path) { - Ok(c) => c, - Err(e) => { - issues.push(LintIssue { - file: file_name, - entry_id: None, - message: format!("Cannot read file: {e}"), - }); - continue; - } - }; + let content = std::fs::read_to_string(path) + .map_err(|e| GratError::TaxonomyError(format!("Cannot read file {file_name}: {e}")))?; - let schema = match TaxonomyParser::parse(&content) { + // Attempt strict deserialization. If it fails, extract line info from the error. + let schema: TaxonomySchema = match toml::from_str(&content) { Ok(s) => s, Err(e) => { - issues.push(LintIssue { - file: file_name, - entry_id: None, - message: format!("TOML parse error: {e}"), - }); - continue; + let span = e.span(); + let (line, col) = if let Some(span) = span { + let mut line = 1; + let mut col = 1; + for &ch in content.as_bytes().iter().take(span.start) { + if ch == b'\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + (line, col) + } else { + (1, 1) + }; + panic!( + "Taxonomy TOML parse error in {} at line {} (column {}): {}", + file_name, line, col, e + ); } }; + // Check category description length + if schema.category.description.trim().len() < MIN_DESCRIPTION_LEN { + let line_num = content + .lines() + .position(|l| l.trim().starts_with("description =")) + .map(|idx| idx + 1) + .unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: category description is too short (must be at least {} characters)", + file_name, line_num, MIN_DESCRIPTION_LEN + ); + } + for entry in schema.errors { let entry_id = entry.id.clone(); - // ── id must be non-empty ───────────────────────────────── + // id must be non-empty if entry.id.trim().is_empty() { - issues.push(LintIssue { - file: file_name.clone(), - entry_id: Some(entry_id.clone()), - message: "id is empty".to_string(), - }); + let line_num = get_line_number(&content, &entry_id, None).unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: entry id is empty", + file_name, line_num + ); } - // ── name must be non-empty ─────────────────────────────── + // name must be non-empty if entry.name.trim().is_empty() { - issues.push(LintIssue { - file: file_name.clone(), - entry_id: Some(entry_id.clone()), - message: "name is empty".to_string(), - }); + let line_num = get_line_number(&content, &entry_id, Some("name")).unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: name is empty", + file_name, line_num + ); } - // ── summary must be non-empty ──────────────────────────── - if entry.summary.trim().is_empty() { - issues.push(LintIssue { - file: file_name.clone(), - entry_id: Some(entry_id.clone()), - message: "summary is empty".to_string(), - }); + // summary must meet length requirement + if entry.summary.trim().len() < MIN_DESCRIPTION_LEN { + let line_num = get_line_number(&content, &entry_id, Some("summary")).unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: summary for entry '{}' is too short (must be at least {} characters)", + file_name, line_num, entry_id, MIN_DESCRIPTION_LEN + ); } - // ── detailed_explanation must be non-empty ─────────────── - if entry.detailed_explanation.trim().is_empty() { - issues.push(LintIssue { - file: file_name.clone(), - entry_id: Some(entry_id.clone()), - message: "detailed_explanation is empty".to_string(), - }); + // detailed_explanation must meet length requirement + if entry.detailed_explanation.trim().len() < MIN_DESCRIPTION_LEN { + let line_num = + get_line_number(&content, &entry_id, Some("detailed_explanation")).unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: detailed_explanation for entry '{}' is too short (must be at least {} characters)", + file_name, line_num, entry_id, MIN_DESCRIPTION_LEN + ); } - // ── severity must be a known value ─────────────────────── - // Checked against actual data files: Error, Warning, Info, Fatal + // severity must be a known value const VALID_SEVERITIES: &[&str] = &["Error", "Warning", "Info", "Fatal"]; if !VALID_SEVERITIES.contains(&entry.severity.as_str()) { - issues.push(LintIssue { - file: file_name.clone(), - entry_id: Some(entry_id.clone()), - message: format!( - "invalid severity '{}': must be one of {}", - entry.severity, - VALID_SEVERITIES.join(", "), - ), - }); + let line_num = get_line_number(&content, &entry_id, Some("severity")).unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: invalid severity '{}': must be one of {}", + file_name, line_num, entry.severity, VALID_SEVERITIES.join(", ") + ); } - // ── since_protocol > 0 when present ────────────────────── + // since_protocol > 0 when present if let Some(sp) = entry.since_protocol { if sp == 0 { - issues.push(LintIssue { - file: file_name.clone(), - entry_id: Some(entry_id.clone()), - message: "since_protocol must be > 0".to_string(), - }); + let line_num = + get_line_number(&content, &entry_id, Some("since_protocol")).unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: since_protocol must be > 0", + file_name, line_num + ); } } - // ── deprecated_protocol >= since_protocol ──────────────── + // deprecated_protocol >= since_protocol if let (Some(dp), Some(sp)) = (entry.deprecated_protocol, entry.since_protocol) { if dp < sp { - issues.push(LintIssue { - file: file_name.clone(), - entry_id: Some(entry_id.clone()), - message: format!( - "deprecated_protocol ({dp}) must be >= since_protocol ({sp})" - ), - }); + let line_num = + get_line_number(&content, &entry_id, Some("deprecated_protocol")) + .unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: deprecated_protocol ({dp}) must be >= since_protocol ({sp})", + file_name, line_num + ); } } - // ── documentation_url must parse as a URL when present ─── - // Structural validation only – no live HTTP requests. + // documentation_url must parse as a URL when present if let Some(ref doc_url) = entry.documentation_url { if url::Url::parse(doc_url).is_err() { - issues.push(LintIssue { - file: file_name.clone(), - entry_id: Some(entry_id.clone()), - message: format!("documentation_url '{doc_url}' is not a valid URL"), - }); + let line_num = get_line_number(&content, &entry_id, Some("documentation_url")) + .unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: documentation_url '{doc_url}' is not a valid URL", + file_name, line_num + ); } } - all_entries.push((file_name.clone(), entry)); + // check causes descriptions + for cause in &entry.common_causes { + if cause.description.trim().len() < MIN_DESCRIPTION_LEN { + let line_num = + get_line_number(&content, &entry_id, Some("description")).unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: common cause description is too short (must be at least {} characters)", + file_name, line_num, MIN_DESCRIPTION_LEN + ); + } + } + + // check fixes descriptions + for fix in &entry.suggested_fixes { + if fix.description.trim().len() < MIN_DESCRIPTION_LEN { + let line_num = + get_line_number(&content, &entry_id, Some("description")).unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: suggested fix description is too short (must be at least {} characters)", + file_name, line_num, MIN_DESCRIPTION_LEN + ); + } + } + + all_entries.push((file_name.clone(), content.clone(), entry)); } } - // ------------------------------------------------------------------ // 3. Cross-entry checks - // ------------------------------------------------------------------ - // ── Duplicate (category, code) pairs ────────────────────────────── - let mut seen: HashMap<(ErrorCategory, u32), String> = HashMap::new(); - for (file_name, entry) in &all_entries { + // Duplicate (category, code) pairs + let mut seen: HashMap<(ErrorCategory, u32), (String, String)> = HashMap::new(); + for (file_name, content, entry) in &all_entries { let key = (entry.category.clone(), entry.code); - if let Some(prev_file) = seen.get(&key) { - issues.push(LintIssue { - file: file_name.clone(), - entry_id: Some(entry.id.clone()), - message: format!( - "duplicate (category, code) pair ({}, {}) already defined in {}", - entry.category, entry.code, prev_file, - ), - }); + if let Some((prev_file, prev_id)) = seen.get(&key) { + let line_num = get_line_number(content, &entry.id, Some("code")).unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: duplicate (category, code) pair ({}, {}) already defined by entry '{}' in {}", + file_name, line_num, entry.category, entry.code, prev_id, prev_file + ); } else { - seen.insert(key, file_name.clone()); + seen.insert(key, (file_name.clone(), entry.id.clone())); } } - // ── related_errors should reference existing ids ────────────────── - let all_ids: HashSet<&str> = all_entries.iter().map(|(_, e)| e.id.as_str()).collect(); - for (file_name, entry) in &all_entries { + // related_errors should reference existing ids + let all_ids: HashSet<&str> = all_entries.iter().map(|(_, _, e)| e.id.as_str()).collect(); + for (file_name, content, entry) in &all_entries { for rel in &entry.related_errors { if !all_ids.contains(rel.as_str()) { - issues.push(LintIssue { - file: file_name.clone(), - entry_id: Some(entry.id.clone()), - message: format!( - "related_errors references '{}' which does not exist in any loaded file", - rel, - ), - }); + let line_num = + get_line_number(content, &entry.id, Some("related_errors")).unwrap_or(1); + panic!( + "Taxonomy validation error in {} at line {}: related_errors references '{}' which does not exist in any loaded file", + file_name, line_num, rel + ); } } } - Ok(issues) + Ok(()) } -// ----------------------------------------------------------------------- -// Tests -// ----------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::taxonomy::schema::{CategoryMeta, ErrorCategory, TaxonomyEntry, TaxonomySchema}; - /// Helper: create a minimal valid entry for testing. fn valid_entry(id: &str, category: ErrorCategory, code: u32) -> TaxonomyEntry { TaxonomyEntry { id: id.to_string(), @@ -228,8 +279,8 @@ mod tests { severity: "Error".to_string(), since_protocol: Some(20), deprecated_protocol: None, - summary: "Test summary.".to_string(), - detailed_explanation: "Test detailed explanation.".to_string(), + summary: "Test summary is long enough.".to_string(), + detailed_explanation: "Test detailed explanation is also long enough.".to_string(), common_causes: vec![], suggested_fixes: vec![], related_errors: vec![], @@ -239,7 +290,6 @@ mod tests { } } - /// Helper: write a taxonomy file to a temp dir and return its path. fn write_taxonomy_file( dir: &std::path::Path, name: &str, @@ -248,7 +298,7 @@ mod tests { let schema = TaxonomySchema { category: CategoryMeta { name: "Test".to_string(), - description: "Test data".to_string(), + description: "Test data (must be long enough)".to_string(), source_module: "test".to_string(), }, errors: entries, @@ -271,59 +321,36 @@ mod tests { ], ); - let issues = lint_dir(dir.path()).expect("lint_dir"); - assert!(issues.is_empty(), "expected no issues, got: {issues:?}",); + let result = lint_dir(dir.path()); + assert!(result.is_ok()); } #[test] - fn missing_required_field_fails() { + #[should_panic(expected = "Taxonomy TOML parse error")] + fn unknown_field_fails() { let dir = tempfile::tempdir().expect("temp dir"); - - // Manually craft via toml string so we can omit fields that serde - // treats as required (but may still serialize as empty). let toml_str = r#" [category] name = "Test" -description = "Test data" +description = "Test data (must be long enough)" source_module = "test" [[errors]] -id = "" +id = "test.err" category = "budget" code = 1 -name = "" +name = "TestErr" severity = "Error" -summary = "" -detailed_explanation = "" +summary = "Test summary is long enough." +detailed_explanation = "Test detailed explanation is also long enough." +unknown_field = "oops" "#; std::fs::write(dir.path().join("test.toml"), toml_str).expect("write"); - - let issues = lint_dir(dir.path()).expect("lint_dir"); - assert!( - !issues.is_empty(), - "expected issues for empty required fields" - ); - // Expect at least id empty, name empty, summary empty, detailed_explanation empty - let messages: Vec<&str> = issues.iter().map(|i| i.message.as_str()).collect(); - assert!( - messages.contains(&"id is empty"), - "missing 'id is empty': {messages:?}" - ); - assert!( - messages.contains(&"name is empty"), - "missing 'name is empty': {messages:?}" - ); - assert!( - messages.contains(&"summary is empty"), - "missing 'summary is empty': {messages:?}" - ); - assert!( - messages.contains(&"detailed_explanation is empty"), - "missing 'detailed_explanation is empty': {messages:?}" - ); + let _ = lint_dir(dir.path()); } #[test] + #[should_panic(expected = "duplicate (category, code) pair")] fn duplicate_category_code_fails() { let dir = tempfile::tempdir().expect("temp dir"); write_taxonomy_file( @@ -331,39 +358,22 @@ detailed_explanation = "" "test.toml", vec![ valid_entry("test.dup.a", ErrorCategory::Budget, 1), - valid_entry("test.dup.b", ErrorCategory::Budget, 1), // duplicate! + valid_entry("test.dup.b", ErrorCategory::Budget, 1), ], ); - let issues = lint_dir(dir.path()).expect("lint_dir"); - let dup_issues: Vec<&LintIssue> = issues - .iter() - .filter(|i| i.message.contains("duplicate")) - .collect(); - assert_eq!( - dup_issues.len(), - 1, - "expected 1 duplicate issue, got {dup_issues:?}", - ); + let _ = lint_dir(dir.path()); } #[test] + #[should_panic(expected = "documentation_url")] fn malformed_documentation_url_fails() { let dir = tempfile::tempdir().expect("temp dir"); let mut entry = valid_entry("test.bad.url", ErrorCategory::Budget, 42); entry.documentation_url = Some("not a url".to_string()); write_taxonomy_file(dir.path(), "test.toml", vec![entry]); - let issues = lint_dir(dir.path()).expect("lint_dir"); - let url_issues: Vec<&LintIssue> = issues - .iter() - .filter(|i| i.message.contains("documentation_url")) - .collect(); - assert_eq!( - url_issues.len(), - 1, - "expected 1 url issue, got {url_issues:?}", - ); + let _ = lint_dir(dir.path()); } #[test] @@ -373,91 +383,70 @@ detailed_explanation = "" entry.documentation_url = Some("https://example.com/docs".to_string()); write_taxonomy_file(dir.path(), "test.toml", vec![entry]); - let issues = lint_dir(dir.path()).expect("lint_dir"); - let url_issues: Vec<&LintIssue> = issues - .iter() - .filter(|i| i.message.contains("documentation_url")) - .collect(); - assert!( - url_issues.is_empty(), - "expected no url issues, got {url_issues:?}", - ); + let result = lint_dir(dir.path()); + assert!(result.is_ok()); } #[test] + #[should_panic(expected = "invalid severity")] fn bad_severity_fails() { let dir = tempfile::tempdir().expect("temp dir"); let mut entry = valid_entry("test.bad.severity", ErrorCategory::Budget, 44); entry.severity = "BadSeverity".to_string(); write_taxonomy_file(dir.path(), "test.toml", vec![entry]); - let issues = lint_dir(dir.path()).expect("lint_dir"); - let sev_issues: Vec<&LintIssue> = issues - .iter() - .filter(|i| i.message.contains("severity")) - .collect(); - assert_eq!( - sev_issues.len(), - 1, - "expected 1 severity issue, got {sev_issues:?}", - ); + let _ = lint_dir(dir.path()); } #[test] + #[should_panic(expected = "since_protocol must be > 0")] fn since_protocol_zero_fails() { let dir = tempfile::tempdir().expect("temp dir"); let mut entry = valid_entry("test.bad.sp", ErrorCategory::Budget, 45); entry.since_protocol = Some(0); write_taxonomy_file(dir.path(), "test.toml", vec![entry]); - let issues = lint_dir(dir.path()).expect("lint_dir"); - let sp_issues: Vec<&LintIssue> = issues - .iter() - .filter(|i| i.message.contains("since_protocol")) - .collect(); - assert_eq!( - sp_issues.len(), - 1, - "expected 1 since_protocol issue, got {sp_issues:?}", - ); + let _ = lint_dir(dir.path()); } #[test] + #[should_panic(expected = "deprecated_protocol")] fn deprecated_before_since_fails() { let dir = tempfile::tempdir().expect("temp dir"); let mut entry = valid_entry("test.bad.depr", ErrorCategory::Budget, 46); entry.since_protocol = Some(20); - entry.deprecated_protocol = Some(15); // < since_protocol + entry.deprecated_protocol = Some(15); write_taxonomy_file(dir.path(), "test.toml", vec![entry]); - let issues = lint_dir(dir.path()).expect("lint_dir"); - let dep_issues: Vec<&LintIssue> = issues - .iter() - .filter(|i| i.message.contains("deprecated_protocol")) - .collect(); - assert_eq!( - dep_issues.len(), - 1, - "expected 1 deprecated_protocol issue, got {dep_issues:?}", - ); + let _ = lint_dir(dir.path()); } #[test] + #[should_panic(expected = "related_errors references")] fn unresolved_related_error_fails() { let dir = tempfile::tempdir().expect("temp dir"); let mut entry = valid_entry("test.rel", ErrorCategory::Budget, 47); entry.related_errors = vec!["nonexistent.id".to_string()]; write_taxonomy_file(dir.path(), "test.toml", vec![entry]); - let issues = lint_dir(dir.path()).expect("lint_dir"); - let rel_issues: Vec<&LintIssue> = issues - .iter() - .filter(|i| i.message.contains("related_errors")) - .collect(); - assert_eq!( - rel_issues.len(), - 1, - "expected 1 related_errors issue, got {rel_issues:?}", - ); + let _ = lint_dir(dir.path()); + } + + #[test] + #[should_panic(expected = "summary for entry 'test.short' is too short")] + fn short_summary_fails() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut entry = valid_entry("test.short", ErrorCategory::Budget, 48); + entry.summary = "Error occurred".to_string(); // 14 characters + write_taxonomy_file(dir.path(), "test.toml", vec![entry]); + + let _ = lint_dir(dir.path()); + } + + #[test] + fn run_linter_on_production_taxonomy_data() { + let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push("src/taxonomy/data"); + lint_dir(&p).expect("Linter failed on production data"); } } diff --git a/crates/core/src/taxonomy/loader.rs b/crates/core/src/taxonomy/loader.rs index 5350bf02..3e45ebb0 100644 --- a/crates/core/src/taxonomy/loader.rs +++ b/crates/core/src/taxonomy/loader.rs @@ -184,6 +184,7 @@ mod tests { } #[test] + #[cfg(feature = "decode")] fn taxonomy_covers_tier1_static_mapping_codes() { let db = TaxonomyDatabase::load_embedded().expect("Taxonomy should load"); diff --git a/crates/core/src/taxonomy/schema.rs b/crates/core/src/taxonomy/schema.rs index 1e38914f..661c2afb 100644 --- a/crates/core/src/taxonomy/schema.rs +++ b/crates/core/src/taxonomy/schema.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TaxonomyEntry { pub id: String, @@ -37,6 +38,7 @@ pub struct TaxonomyEntry { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TaxonomyCause { pub description: String, @@ -44,6 +46,7 @@ pub struct TaxonomyCause { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TaxonomyFix { pub description: String, @@ -91,12 +94,14 @@ impl std::fmt::Display for ErrorCategory { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TaxonomySchema { pub category: CategoryMeta, pub errors: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CategoryMeta { pub name: String, pub description: String, diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 637f0250..3988f59c 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -144,6 +144,8 @@ fn decode_report_inner(tx_result_json: &str) -> Result read_bytes_limit: 0, write_bytes: 0, }, + operation_index: None, + operation_count: None, }); } }