1use schemars::JsonSchema;
7use semver::Version;
8use serde::{
9 de::{self, DeserializeOwned, MapAccess, SeqAccess, Visitor},
10 Deserialize, Deserializer, Serialize,
11};
12use sha2::{Digest, Sha256};
13use std::{
14 collections::{BTreeMap, BTreeSet},
15 fmt,
16 io::Cursor,
17};
18use stellar_xdr::{
19 Error as XdrError, Limited, Limits, ReadXdr, ScEnvMetaEntry, ScMetaEntry, ScMetaV0,
20 ScSpecEntry, ScSpecTypeDef, ScSpecUdtUnionCaseV0,
21};
22
23const SPEC_XDR_DEPTH_LIMIT: u32 = 500;
24pub const MAX_ARTIFACT_SIZE_BYTES: usize = 16 * 1024 * 1024;
25const MAX_PUBLIC_IMPACT_DEPTH: usize = 64;
26const MAX_PUBLIC_IMPACT_PATHS: usize = 256;
27const MAX_PUBLIC_IMPACT_STEPS: usize = 10_000;
28const CAP_0086_PROTOCOL: u32 = 28;
29const CAP_0086_SPARSE_WRITE_IMPORT: &str = "m.b";
30const CAP_0086_SPARSE_READ_IMPORT: &str = "m.c";
31const UPDATE_CURRENT_CONTRACT_WASM_IMPORT: &str = "l.6";
32
33#[derive(Debug, thiserror::Error)]
34pub enum Error {
35 #[error("WASM artifact is {size_bytes} bytes. The parser limit is {limit_bytes} bytes")]
36 ArtifactTooLarge {
37 size_bytes: usize,
38 limit_bytes: usize,
39 },
40 #[error("invalid Soroban contract specification: {0}")]
41 Spec(#[from] soroban_spec::read::FromWasmError),
42 #[error("expected exactly one contractspecv0 section, found {0}")]
43 ContractSpecSectionCount(usize),
44 #[error("expected exactly one contractenvmetav0 section, found {0}")]
45 ContractEnvMetaSectionCount(usize),
46 #[error("expected exactly one environment interface version, found {0}")]
47 ContractEnvVersionCount(usize),
48 #[error("contract specification contains duplicate {kind} name {name:?}")]
49 DuplicateSpecName { kind: &'static str, name: String },
50 #[error("contract specification {kind} {owner:?} contains duplicate member {name:?}")]
51 DuplicateSpecMember {
52 kind: &'static str,
53 owner: String,
54 name: String,
55 },
56 #[error("contract metadata contains duplicate key {0:?}")]
57 DuplicateMetadataKey(String),
58 #[error("invalid WASM binary: {0}")]
59 Wasm(#[from] wasmparser::BinaryReaderError),
60 #[error("incomplete WASM call-graph evidence: {0}")]
61 CallGraph(String),
62 #[error("invalid XDR metadata: {0}")]
63 Xdr(#[from] XdrError),
64 #[error("invalid JSON: {0}")]
65 Json(#[from] serde_json::Error),
66 #[error("invalid contract ID {0:?}. Expected a checksummed Stellar C... strkey")]
67 InvalidContractId(String),
68 #[error("invalid upgrade plan: {0}")]
69 Plan(String),
70}
71
72struct UniqueJson;
73
74impl<'de> Deserialize<'de> for UniqueJson {
75 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
76 where
77 D: Deserializer<'de>,
78 {
79 deserializer.deserialize_any(UniqueJsonVisitor)
80 }
81}
82
83struct UniqueJsonVisitor;
84
85impl<'de> Visitor<'de> for UniqueJsonVisitor {
86 type Value = UniqueJson;
87
88 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89 formatter.write_str("JSON without duplicate object keys")
90 }
91
92 fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E> {
93 Ok(UniqueJson)
94 }
95
96 fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E> {
97 Ok(UniqueJson)
98 }
99
100 fn visit_u64<E>(self, _value: u64) -> Result<Self::Value, E> {
101 Ok(UniqueJson)
102 }
103
104 fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> {
105 Ok(UniqueJson)
106 }
107
108 fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E> {
109 Ok(UniqueJson)
110 }
111
112 fn visit_string<E>(self, _value: String) -> Result<Self::Value, E> {
113 Ok(UniqueJson)
114 }
115
116 fn visit_unit<E>(self) -> Result<Self::Value, E> {
117 Ok(UniqueJson)
118 }
119
120 fn visit_none<E>(self) -> Result<Self::Value, E> {
121 Ok(UniqueJson)
122 }
123
124 fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
125 where
126 D: Deserializer<'de>,
127 {
128 UniqueJson::deserialize(deserializer)
129 }
130
131 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
132 where
133 A: SeqAccess<'de>,
134 {
135 while sequence.next_element::<UniqueJson>()?.is_some() {}
136 Ok(UniqueJson)
137 }
138
139 fn visit_map<A>(self, mut object: A) -> Result<Self::Value, A::Error>
140 where
141 A: MapAccess<'de>,
142 {
143 let mut keys = BTreeSet::new();
144 while let Some(key) = object.next_key::<String>()? {
145 if !keys.insert(key.clone()) {
146 return Err(de::Error::custom(format!(
147 "duplicate JSON object key {key:?}"
148 )));
149 }
150 object.next_value::<UniqueJson>()?;
151 }
152 Ok(UniqueJson)
153 }
154}
155
156fn parse_json_strict<T>(bytes: &[u8]) -> Result<T, Error>
157where
158 T: DeserializeOwned,
159{
160 let mut duplicate_check = serde_json::Deserializer::from_slice(bytes);
161 UniqueJson::deserialize(&mut duplicate_check)?;
162 duplicate_check.end()?;
163 Ok(serde_json::from_slice(bytes)?)
164}
165
166#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum Severity {
169 Info,
170 Warning,
171 Error,
172}
173
174#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct Finding {
177 pub code: String,
178 pub severity: Severity,
179 pub title: String,
180 pub detail: String,
181 pub remediation: String,
182}
183
184#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct InterfaceEntry {
187 pub kind: String,
188 pub name: String,
189 pub canonical: serde_json::Value,
190}
191
192#[derive(
193 Clone, Copy, Debug, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
194)]
195#[serde(rename_all = "snake_case")]
196pub enum BoundaryPosition {
197 Input,
198 Output,
199}
200
201#[derive(Clone, Debug, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase", deny_unknown_fields)]
203pub struct PublicTypeBoundary {
204 pub function: String,
205 pub position: BoundaryPosition,
206 pub index: usize,
207 pub label: Option<String>,
208 pub root_type: String,
209}
210
211#[derive(Clone, Debug, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase", deny_unknown_fields)]
213pub struct TypeReference {
214 pub owner_type: String,
215 pub member: String,
216 pub target_type: String,
217}
218
219#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase", deny_unknown_fields)]
221pub struct ExportCallEvidence {
222 pub host_imports: BTreeSet<String>,
223 pub dynamic_dispatch_reachable: bool,
224}
225
226#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase", deny_unknown_fields)]
228pub struct FunctionImport {
229 pub function_index: u32,
230 pub module: String,
231 pub name: String,
232}
233
234impl FunctionImport {
235 pub fn canonical_name(&self) -> String {
236 format!("{}.{}", self.module, self.name)
237 }
238}
239
240#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase", deny_unknown_fields)]
242pub struct Artifact {
243 pub format_version: u32,
244 pub sha256: String,
245 pub size_bytes: usize,
246 pub env_protocol_version: u32,
247 pub env_pre_release: u32,
248 pub metadata: BTreeMap<String, String>,
249 pub host_imports: BTreeSet<String>,
250 pub functions: BTreeMap<String, InterfaceEntry>,
251 pub events: BTreeMap<String, InterfaceEntry>,
252 pub user_types: BTreeMap<String, InterfaceEntry>,
253 pub public_type_boundaries: Vec<PublicTypeBoundary>,
254 pub type_references: Vec<TypeReference>,
255 pub export_call_evidence: BTreeMap<String, ExportCallEvidence>,
256}
257
258impl Artifact {
259 pub fn from_wasm(bytes: &[u8]) -> Result<Self, Error> {
260 if bytes.len() > MAX_ARTIFACT_SIZE_BYTES {
261 return Err(Error::ArtifactTooLarge {
262 size_bytes: bytes.len(),
263 limit_bytes: MAX_ARTIFACT_SIZE_BYTES,
264 });
265 }
266 wasmparser::Validator::new().validate_all(bytes)?;
267 let sha256 = hex::encode(Sha256::digest(bytes));
268 require_single_contract_spec_section(bytes)?;
269 let (env_protocol_version, env_pre_release) = read_contract_env_metadata(bytes)?;
270 let spec = soroban_spec::read::from_wasm(bytes)?;
271 let metadata = read_contract_metadata(bytes)?;
272 let host_imports = read_host_imports(bytes)?;
273 let export_call_evidence = inspect_export_call_evidence(bytes)?;
274 let mut functions = BTreeMap::new();
275 let mut events = BTreeMap::new();
276 let mut user_types = BTreeMap::new();
277 let mut public_type_boundaries = BTreeSet::new();
278 let mut type_references = BTreeSet::new();
279
280 for entry in spec.iter() {
281 let canonical = canonicalize_spec_entry(entry)?;
282 match entry {
283 ScSpecEntry::FunctionV0(function) => {
284 let name = function.name.to_utf8_string_lossy();
285 ensure_unique_spec_members(
286 "function",
287 &name,
288 function
289 .inputs
290 .iter()
291 .map(|input| input.name.to_utf8_string_lossy()),
292 )?;
293 for (index, input) in function.inputs.iter().enumerate() {
294 let mut referenced = BTreeSet::new();
295 collect_udt_names(&input.type_, &mut referenced);
296 for root_type in referenced {
297 public_type_boundaries.insert(PublicTypeBoundary {
298 function: name.clone(),
299 position: BoundaryPosition::Input,
300 index,
301 label: Some(input.name.to_utf8_string_lossy()),
302 root_type,
303 });
304 }
305 }
306 for (index, output) in function.outputs.iter().enumerate() {
307 let mut referenced = BTreeSet::new();
308 collect_udt_names(output, &mut referenced);
309 for root_type in referenced {
310 public_type_boundaries.insert(PublicTypeBoundary {
311 function: name.clone(),
312 position: BoundaryPosition::Output,
313 index,
314 label: None,
315 root_type,
316 });
317 }
318 }
319 if functions
320 .insert(
321 name.clone(),
322 InterfaceEntry {
323 kind: "function".into(),
324 name: name.clone(),
325 canonical,
326 },
327 )
328 .is_some()
329 {
330 return Err(Error::DuplicateSpecName {
331 kind: "function",
332 name,
333 });
334 }
335 }
336 ScSpecEntry::UdtStructV0(value) => {
337 let owner_type = value.name.to_utf8_string_lossy();
338 ensure_unique_spec_members(
339 "struct",
340 &owner_type,
341 value
342 .fields
343 .iter()
344 .map(|field| field.name.to_utf8_string_lossy()),
345 )?;
346 for field in value.fields.iter() {
347 let mut referenced = BTreeSet::new();
348 collect_udt_names(&field.type_, &mut referenced);
349 for target_type in referenced {
350 type_references.insert(TypeReference {
351 owner_type: owner_type.clone(),
352 member: field.name.to_utf8_string_lossy(),
353 target_type,
354 });
355 }
356 }
357 insert_user_type(&mut user_types, "struct", owner_type, canonical)?;
358 }
359 ScSpecEntry::UdtUnionV0(value) => {
360 let owner_type = value.name.to_utf8_string_lossy();
361 ensure_unique_spec_members(
362 "union",
363 &owner_type,
364 value.cases.iter().map(|case| match case {
365 ScSpecUdtUnionCaseV0::VoidV0(case) => case.name.to_utf8_string_lossy(),
366 ScSpecUdtUnionCaseV0::TupleV0(case) => case.name.to_utf8_string_lossy(),
367 }),
368 )?;
369 for case in value.cases.iter() {
370 if let ScSpecUdtUnionCaseV0::TupleV0(tuple) = case {
371 let case_name = tuple.name.to_utf8_string_lossy();
372 for (index, value_type) in tuple.type_.iter().enumerate() {
373 let mut referenced = BTreeSet::new();
374 collect_udt_names(value_type, &mut referenced);
375 for target_type in referenced {
376 type_references.insert(TypeReference {
377 owner_type: owner_type.clone(),
378 member: format!("{case_name}[{index}]"),
379 target_type,
380 });
381 }
382 }
383 }
384 }
385 insert_user_type(&mut user_types, "union", owner_type, canonical)?;
386 }
387 ScSpecEntry::UdtEnumV0(value) => {
388 let name = value.name.to_utf8_string_lossy();
389 ensure_unique_spec_members(
390 "enum",
391 &name,
392 value
393 .cases
394 .iter()
395 .map(|case| case.name.to_utf8_string_lossy()),
396 )?;
397 insert_user_type(&mut user_types, "enum", name, canonical)?;
398 }
399 ScSpecEntry::UdtErrorEnumV0(value) => {
400 let name = value.name.to_utf8_string_lossy();
401 ensure_unique_spec_members(
402 "error enum",
403 &name,
404 value
405 .cases
406 .iter()
407 .map(|case| case.name.to_utf8_string_lossy()),
408 )?;
409 insert_user_type(&mut user_types, "error_enum", name, canonical)?;
410 }
411 ScSpecEntry::EventV0(value) => {
412 let name = value.name.to_utf8_string_lossy();
413 ensure_unique_spec_members(
414 "event",
415 &name,
416 value
417 .params
418 .iter()
419 .map(|param| param.name.to_utf8_string_lossy()),
420 )?;
421 if events
422 .insert(
423 name.clone(),
424 InterfaceEntry {
425 kind: "event".into(),
426 name: name.clone(),
427 canonical,
428 },
429 )
430 .is_some()
431 {
432 return Err(Error::DuplicateSpecName {
433 kind: "event",
434 name,
435 });
436 }
437 }
438 }
439 }
440
441 Ok(Self {
442 format_version: 1,
443 sha256,
444 size_bytes: bytes.len(),
445 env_protocol_version,
446 env_pre_release,
447 metadata,
448 host_imports,
449 functions,
450 events,
451 user_types,
452 public_type_boundaries: public_type_boundaries.into_iter().collect(),
453 type_references: type_references.into_iter().collect(),
454 export_call_evidence,
455 })
456 }
457
458 pub fn version(&self) -> Option<&str> {
459 self.metadata.get("binver").map(String::as_str)
460 }
461
462 pub fn has_function(&self, name: &str) -> bool {
463 self.functions.contains_key(name)
464 }
465
466 pub fn uses_cap_0086_sparse_read(&self) -> bool {
467 self.host_imports.contains(CAP_0086_SPARSE_READ_IMPORT)
468 }
469
470 pub fn uses_cap_0086_sparse_write(&self) -> bool {
471 self.host_imports.contains(CAP_0086_SPARSE_WRITE_IMPORT)
472 }
473}
474
475fn collect_udt_names(value_type: &ScSpecTypeDef, destination: &mut BTreeSet<String>) {
476 match value_type {
477 ScSpecTypeDef::Udt(value) => {
478 destination.insert(value.name.to_utf8_string_lossy());
479 }
480 ScSpecTypeDef::Option(value) => collect_udt_names(&value.value_type, destination),
481 ScSpecTypeDef::Result(value) => {
482 collect_udt_names(&value.ok_type, destination);
483 collect_udt_names(&value.error_type, destination);
484 }
485 ScSpecTypeDef::Vec(value) => collect_udt_names(&value.element_type, destination),
486 ScSpecTypeDef::Map(value) => {
487 collect_udt_names(&value.key_type, destination);
488 collect_udt_names(&value.value_type, destination);
489 }
490 ScSpecTypeDef::Tuple(value) => {
491 for member in value.value_types.iter() {
492 collect_udt_names(member, destination);
493 }
494 }
495 ScSpecTypeDef::Val
496 | ScSpecTypeDef::Bool
497 | ScSpecTypeDef::Void
498 | ScSpecTypeDef::Error
499 | ScSpecTypeDef::U32
500 | ScSpecTypeDef::I32
501 | ScSpecTypeDef::U64
502 | ScSpecTypeDef::I64
503 | ScSpecTypeDef::Timepoint
504 | ScSpecTypeDef::Duration
505 | ScSpecTypeDef::U128
506 | ScSpecTypeDef::I128
507 | ScSpecTypeDef::U256
508 | ScSpecTypeDef::I256
509 | ScSpecTypeDef::Bytes
510 | ScSpecTypeDef::String
511 | ScSpecTypeDef::Symbol
512 | ScSpecTypeDef::Address
513 | ScSpecTypeDef::MuxedAddress
514 | ScSpecTypeDef::BytesN(_) => {}
515 }
516}
517
518fn require_single_contract_spec_section(bytes: &[u8]) -> Result<(), Error> {
519 let mut count = 0;
520 for payload in wasmparser::Parser::new(0).parse_all(bytes) {
521 if let wasmparser::Payload::CustomSection(section) = payload? {
522 count += usize::from(section.name() == "contractspecv0");
523 }
524 }
525 if count == 1 {
526 Ok(())
527 } else {
528 Err(Error::ContractSpecSectionCount(count))
529 }
530}
531
532fn canonicalize_spec_entry(entry: &ScSpecEntry) -> Result<serde_json::Value, serde_json::Error> {
533 let mut value = serde_json::to_value(entry)?;
534 strip_documentation(&mut value);
535 Ok(value)
536}
537
538fn strip_documentation(value: &mut serde_json::Value) {
541 match value {
542 serde_json::Value::Object(object) => {
543 object.remove("doc");
544 for child in object.values_mut() {
545 strip_documentation(child);
546 }
547 }
548 serde_json::Value::Array(values) => {
549 for child in values {
550 strip_documentation(child);
551 }
552 }
553 _ => {}
554 }
555}
556
557fn ensure_unique_spec_members(
558 kind: &'static str,
559 owner: &str,
560 names: impl IntoIterator<Item = String>,
561) -> Result<(), Error> {
562 let mut unique = BTreeSet::new();
563 for name in names {
564 if !unique.insert(name.clone()) {
565 return Err(Error::DuplicateSpecMember {
566 kind,
567 owner: owner.into(),
568 name,
569 });
570 }
571 }
572 Ok(())
573}
574
575fn insert_user_type(
576 destination: &mut BTreeMap<String, InterfaceEntry>,
577 kind: &str,
578 name: String,
579 canonical: serde_json::Value,
580) -> Result<(), Error> {
581 if destination
582 .insert(
583 name.clone(),
584 InterfaceEntry {
585 kind: kind.into(),
586 name: name.clone(),
587 canonical,
588 },
589 )
590 .is_some()
591 {
592 Err(Error::DuplicateSpecName {
593 kind: "user-defined type",
594 name,
595 })
596 } else {
597 Ok(())
598 }
599}
600
601fn read_contract_metadata(bytes: &[u8]) -> Result<BTreeMap<String, String>, Error> {
602 let mut raw = Vec::new();
603 for payload in wasmparser::Parser::new(0).parse_all(bytes) {
604 if let wasmparser::Payload::CustomSection(section) = payload? {
605 if section.name() == "contractmetav0" {
606 raw.extend_from_slice(section.data());
607 }
608 }
609 }
610
611 let cursor = Cursor::new(raw);
612 let mut reader = Limited::new(cursor, Limits::depth(SPEC_XDR_DEPTH_LIMIT));
613 let entries = ScMetaEntry::read_xdr_iter(&mut reader).collect::<Result<Vec<_>, _>>()?;
614 let mut metadata = BTreeMap::new();
615 for entry in entries {
616 let ScMetaEntry::ScMetaV0(ScMetaV0 { key, val }) = entry;
617 let key = key.to_utf8_string_lossy();
618 if metadata
619 .insert(key.clone(), val.to_utf8_string_lossy())
620 .is_some()
621 {
622 return Err(Error::DuplicateMetadataKey(key));
623 }
624 }
625 Ok(metadata)
626}
627
628fn read_contract_env_metadata(bytes: &[u8]) -> Result<(u32, u32), Error> {
629 let mut raw = Vec::new();
630 let mut section_count = 0;
631 for payload in wasmparser::Parser::new(0).parse_all(bytes) {
632 if let wasmparser::Payload::CustomSection(section) = payload? {
633 if section.name() == "contractenvmetav0" {
634 section_count += 1;
635 raw.extend_from_slice(section.data());
636 }
637 }
638 }
639 if section_count != 1 {
640 return Err(Error::ContractEnvMetaSectionCount(section_count));
641 }
642
643 let cursor = Cursor::new(raw);
644 let mut reader = Limited::new(cursor, Limits::depth(SPEC_XDR_DEPTH_LIMIT));
645 let entries = ScEnvMetaEntry::read_xdr_iter(&mut reader).collect::<Result<Vec<_>, _>>()?;
646 if entries.len() != 1 {
647 return Err(Error::ContractEnvVersionCount(entries.len()));
648 }
649 let ScEnvMetaEntry::ScEnvMetaKindInterfaceVersion(version) = &entries[0];
650 Ok((version.protocol, version.pre_release))
651}
652
653#[derive(Default)]
654struct FunctionBodyCalls {
655 direct_calls: Vec<u32>,
656 has_dynamic_dispatch: bool,
657}
658
659fn read_host_imports(bytes: &[u8]) -> Result<BTreeSet<String>, Error> {
660 Ok(inspect_function_imports(bytes)?
661 .into_iter()
662 .map(|import| import.canonical_name())
663 .collect())
664}
665
666pub fn inspect_function_imports(bytes: &[u8]) -> Result<Vec<FunctionImport>, Error> {
667 let mut imports = Vec::new();
668 for payload in wasmparser::Parser::new(0).parse_all(bytes) {
669 if let wasmparser::Payload::ImportSection(section) = payload? {
670 for import in section.into_imports() {
671 let import = import?;
672 if matches!(import.ty, wasmparser::TypeRef::Func(_)) {
673 let function_index = u32::try_from(imports.len()).map_err(|_| {
674 Error::CallGraph("too many function imports to inspect".into())
675 })?;
676 imports.push(FunctionImport {
677 function_index,
678 module: import.module.to_owned(),
679 name: import.name.to_owned(),
680 });
681 }
682 }
683 }
684 }
685 Ok(imports)
686}
687
688pub fn inspect_export_call_evidence(
689 bytes: &[u8],
690) -> Result<BTreeMap<String, ExportCallEvidence>, Error> {
691 let mut function_imports = Vec::new();
692 let mut function_exports = BTreeMap::new();
693 let mut function_bodies = Vec::new();
694
695 for payload in wasmparser::Parser::new(0).parse_all(bytes) {
696 match payload? {
697 wasmparser::Payload::ImportSection(section) => {
698 for import in section.into_imports() {
699 let import = import?;
700 if matches!(import.ty, wasmparser::TypeRef::Func(_)) {
701 function_imports.push(format!("{}.{}", import.module, import.name));
702 }
703 }
704 }
705 wasmparser::Payload::ExportSection(section) => {
706 for export in section {
707 let export = export?;
708 if export.kind == wasmparser::ExternalKind::Func {
709 function_exports.insert(export.name.to_owned(), export.index);
710 }
711 }
712 }
713 wasmparser::Payload::CodeSectionEntry(body) => {
714 let mut calls = FunctionBodyCalls::default();
715 let mut operators = body.get_operators_reader()?;
716 while !operators.eof() {
717 match operators.read()? {
718 wasmparser::Operator::Call { function_index }
719 | wasmparser::Operator::ReturnCall { function_index } => {
720 calls.direct_calls.push(function_index);
721 }
722 wasmparser::Operator::CallIndirect { .. }
723 | wasmparser::Operator::ReturnCallIndirect { .. }
724 | wasmparser::Operator::CallRef { .. }
725 | wasmparser::Operator::ReturnCallRef { .. } => {
726 calls.has_dynamic_dispatch = true;
727 }
728 _ => {}
729 }
730 }
731 function_bodies.push(calls);
732 }
733 _ => {}
734 }
735 }
736
737 let imported_function_count = u32::try_from(function_imports.len())
738 .map_err(|_| Error::CallGraph("too many function imports to inspect".into()))?;
739 let mut result = BTreeMap::new();
740 for (export, function_index) in function_exports {
741 let mut visited = BTreeSet::new();
742 let mut evidence = ExportCallEvidence::default();
743 collect_export_call_evidence(
744 function_index,
745 imported_function_count,
746 &function_imports,
747 &function_bodies,
748 &mut visited,
749 &mut evidence,
750 )?;
751 result.insert(export, evidence);
752 }
753 Ok(result)
754}
755
756fn collect_export_call_evidence(
757 function_index: u32,
758 imported_function_count: u32,
759 function_imports: &[String],
760 function_bodies: &[FunctionBodyCalls],
761 visited: &mut BTreeSet<u32>,
762 evidence: &mut ExportCallEvidence,
763) -> Result<(), Error> {
764 let mut pending = vec![function_index];
765 while let Some(current) = pending.pop() {
766 if current < imported_function_count {
767 let import = function_imports
768 .get(current as usize)
769 .ok_or_else(|| Error::CallGraph("function import index is out of range".into()))?;
770 evidence.host_imports.insert(import.clone());
771 continue;
772 }
773 if !visited.insert(current) {
774 continue;
775 }
776
777 let body_index = usize::try_from(current - imported_function_count).map_err(|_| {
778 Error::CallGraph("function body index does not fit this platform".into())
779 })?;
780 let body = function_bodies
781 .get(body_index)
782 .ok_or_else(|| Error::CallGraph("function body index is out of range".into()))?;
783 evidence.dynamic_dispatch_reachable |= body.has_dynamic_dispatch;
784 pending.extend(body.direct_calls.iter().rev().copied());
785 }
786 Ok(())
787}
788
789#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
790#[serde(rename_all = "snake_case")]
791pub enum ProtocolSource {
792 #[default]
793 Unpinned,
794 OfflineAssertion,
795 StellarCliNetworkInfo,
796}
797
798#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
799#[serde(rename_all = "camelCase", deny_unknown_fields)]
800pub struct ValidationContext {
801 pub target_protocol_version: Option<u32>,
802 pub protocol_source: ProtocolSource,
803 pub network_name: Option<String>,
804 pub network_id: Option<String>,
805 pub network_passphrase: Option<String>,
806 pub rpc_version: Option<String>,
807 pub captive_core_version: Option<String>,
808 pub observed_at_unix_seconds: Option<u64>,
809}
810
811#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
812#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
813pub struct Policy {
814 pub format_version: u32,
815 pub name: String,
816 pub require_upgrade_function: bool,
817 pub forbid_constructor: bool,
818 pub require_semver_increase: bool,
819 pub require_storage_schema: bool,
820 pub require_schema_history: bool,
821 pub deny_removed_functions: bool,
822 pub deny_changed_functions: bool,
823 pub deny_removed_events: bool,
824 pub deny_changed_events: bool,
825 pub deny_changed_user_types: bool,
826}
827
828impl Default for Policy {
829 fn default() -> Self {
830 Self {
831 format_version: 1,
832 name: "soroban-upgrades-default".into(),
833 require_upgrade_function: true,
834 forbid_constructor: false,
835 require_semver_increase: true,
836 require_storage_schema: true,
837 require_schema_history: true,
838 deny_removed_functions: true,
839 deny_changed_functions: true,
840 deny_removed_events: true,
841 deny_changed_events: true,
842 deny_changed_user_types: true,
843 }
844 }
845}
846
847impl Policy {
848 pub fn from_json(bytes: &[u8]) -> Result<Self, Error> {
849 parse_json_strict(bytes)
850 }
851}
852
853#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
854#[serde(deny_unknown_fields)]
855pub struct Migration {
856 pub strategy: String,
857 pub entrypoint: Option<String>,
858 pub notes: Option<String>,
859}
860
861#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
862#[serde(rename_all = "snake_case")]
863pub enum Durability {
864 Instance,
865 Persistent,
866 Temporary,
867}
868
869#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
870#[serde(deny_unknown_fields)]
871pub struct StorageEntry {
872 pub key: String,
873 pub durability: Durability,
874 #[serde(rename = "type")]
875 pub value_type: String,
876 #[serde(default)]
877 pub migration: Option<Migration>,
878}
879
880#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
881#[serde(rename_all = "camelCase", deny_unknown_fields)]
882pub struct StorageSchema {
883 pub format_version: u32,
884 pub complete: bool,
885 pub schema_version: u32,
886 pub contract_version: String,
887 pub entries: Vec<StorageEntry>,
888}
889
890impl StorageSchema {
891 pub fn from_json(bytes: &[u8]) -> Result<Self, Error> {
892 parse_json_strict(bytes)
893 }
894}
895
896#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
897#[serde(rename_all = "camelCase", deny_unknown_fields)]
898pub struct HistoricalField {
899 #[serde(rename = "type")]
900 pub value_type: serde_json::Value,
901 pub first_seen: String,
902 #[serde(default)]
903 pub retired_in: Option<String>,
904}
905
906#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
907#[serde(rename_all = "camelCase", deny_unknown_fields)]
908pub struct TypeHistory {
909 #[serde(default)]
910 pub fields: BTreeMap<String, HistoricalField>,
911 #[serde(default)]
912 pub reserved_fields: BTreeSet<String>,
913}
914
915#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
916#[serde(rename_all = "camelCase", deny_unknown_fields)]
917pub struct SchemaHistory {
918 pub format_version: u32,
919 pub complete: bool,
920 pub types: BTreeMap<String, TypeHistory>,
921 #[serde(default)]
922 #[schemars(skip)]
923 pub source_sha256: String,
924}
925
926impl SchemaHistory {
927 pub fn from_json(bytes: &[u8]) -> Result<Self, Error> {
928 let mut history: Self = parse_json_strict(bytes)?;
929 history.source_sha256 = hex::encode(Sha256::digest(bytes));
930 Ok(history)
931 }
932}
933
934#[derive(
935 Clone, Copy, Debug, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
936)]
937#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
938pub enum EvidenceStatus {
939 Fact,
940 Inference,
941 Unknown,
942}
943
944#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
945#[serde(rename_all = "camelCase", deny_unknown_fields)]
946pub struct EvidenceItem {
947 pub status: EvidenceStatus,
948 pub claim: String,
949 pub basis: String,
950 pub limitation: Option<String>,
951}
952
953#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
954#[serde(rename_all = "camelCase", deny_unknown_fields)]
955pub struct EvidenceCoverage {
956 pub compiled_contract_spec: EvidenceItem,
957 pub artifact_host_imports: EvidenceItem,
958 pub target_network_protocol: EvidenceItem,
959 pub declared_storage_schema: EvidenceItem,
960 pub declared_schema_history: EvidenceItem,
961 pub ledger_storage_coverage: EvidenceItem,
962 pub deployed_caller_graph: EvidenceItem,
963 pub cap0086_per_type_reader_binding: EvidenceItem,
964 pub migration_completion: EvidenceItem,
965}
966
967#[derive(Clone, Debug, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
968#[serde(rename_all = "camelCase", deny_unknown_fields)]
969pub struct ImpactStep {
970 pub owner_type: String,
971 pub member: String,
972 pub target_type: String,
973}
974
975#[derive(Clone, Debug, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
976#[serde(rename_all = "camelCase", deny_unknown_fields)]
977pub struct PublicImpact {
978 pub changed_type: String,
979 pub boundary: PublicTypeBoundary,
980 pub steps: Vec<ImpactStep>,
981 pub structural_reachability: EvidenceStatus,
982 pub runtime_compatibility: EvidenceStatus,
983}
984
985#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
986#[serde(rename_all = "camelCase", deny_unknown_fields)]
987pub struct ValidationReport {
988 pub format_version: u32,
989 pub tool_version: String,
990 pub safe: bool,
991 pub policy: Policy,
992 pub context: ValidationContext,
993 pub source: Artifact,
994 pub target: Artifact,
995 pub findings: Vec<Finding>,
996 pub public_impacts: Vec<PublicImpact>,
997 pub evidence: EvidenceCoverage,
998 pub storage_schema_checked: bool,
999 pub schema_history_checked: bool,
1000 pub policy_sha256: String,
1001 pub source_schema_sha256: Option<String>,
1002 pub target_schema_sha256: Option<String>,
1003 pub schema_history_sha256: Option<String>,
1004 pub source_schema: Option<StorageSchema>,
1005 pub target_schema: Option<StorageSchema>,
1006 pub schema_history: Option<SchemaHistory>,
1007}
1008
1009pub fn validate(
1010 source: &Artifact,
1011 target: &Artifact,
1012 source_schema: Option<&StorageSchema>,
1013 target_schema: Option<&StorageSchema>,
1014 policy: &Policy,
1015) -> ValidationReport {
1016 validate_with_history(
1017 source,
1018 target,
1019 source_schema,
1020 target_schema,
1021 policy,
1022 &ValidationContext::default(),
1023 None,
1024 )
1025}
1026
1027pub fn validate_with_context(
1028 source: &Artifact,
1029 target: &Artifact,
1030 source_schema: Option<&StorageSchema>,
1031 target_schema: Option<&StorageSchema>,
1032 policy: &Policy,
1033 context: &ValidationContext,
1034) -> ValidationReport {
1035 validate_with_history(
1036 source,
1037 target,
1038 source_schema,
1039 target_schema,
1040 policy,
1041 context,
1042 None,
1043 )
1044}
1045
1046pub fn validate_with_history(
1047 source: &Artifact,
1048 target: &Artifact,
1049 source_schema: Option<&StorageSchema>,
1050 target_schema: Option<&StorageSchema>,
1051 policy: &Policy,
1052 context: &ValidationContext,
1053 schema_history: Option<&SchemaHistory>,
1054) -> ValidationReport {
1055 let mut findings = Vec::new();
1056
1057 check_policy(policy, &mut findings);
1058 check_protocol_context(context, &mut findings);
1059 check_environment_compatibility(target, context, &mut findings);
1060 check_cap_0086(target, context, &mut findings);
1061
1062 if policy.require_upgrade_function && !target.has_function("upgrade") {
1063 findings.push(error(
1064 "UPG001",
1065 "Target removes the upgrade entrypoint",
1066 "The target contract specification has no `upgrade` function. A successful deployment makes subsequent upgrades unavailable without another authorized host update.",
1067 "Retain an authorized `upgrade` entrypoint or explicitly approve immutability as a terminal release.",
1068 ));
1069 }
1070 if policy.require_upgrade_function {
1071 check_upgrade_host_capability(source, "source", "UPG003", &mut findings);
1072 check_upgrade_host_capability(target, "target", "UPG004", &mut findings);
1073 }
1074
1075 if target.has_function("__constructor") {
1076 let constructor = if policy.forbid_constructor {
1077 error(
1078 "UPG002",
1079 "Target contains a constructor",
1080 "Soroban does not invoke `__constructor` when WASM is replaced. Any state initialization placed there will be skipped during this upgrade.",
1081 "Move upgrade-time initialization to an idempotent migration entrypoint and test it against the pre-upgrade state.",
1082 )
1083 } else {
1084 warning(
1085 "UPG002",
1086 "Target constructor will not run during upgrade",
1087 "The target can validly retain a constructor for fresh deployments, but Soroban will not invoke it when replacing WASM.",
1088 "Confirm that upgrade-time initialization is handled by an idempotent migration and that no upgrade invariant depends on the constructor.",
1089 )
1090 };
1091 findings.push(constructor);
1092 }
1093
1094 check_versions(source, target, policy, &mut findings);
1095 compare_interfaces(source, target, policy, context, &mut findings);
1096
1097 if let Some(history) = schema_history {
1098 validate_schema_history(source, target, history, &mut findings);
1099 } else {
1100 let finding = if policy.require_schema_history {
1101 error(
1102 "HIS000",
1103 "Historical field lifecycle was not checked",
1104 "A two-artifact comparison cannot detect reuse of a field name from an older release.",
1105 "Commit a complete schema-history manifest and pass `--schema-history`.",
1106 )
1107 } else {
1108 warning(
1109 "HIS000",
1110 "Historical field lifecycle was not checked",
1111 "A two-artifact comparison cannot detect reuse of a field name that existed in an older release or prove that archived state no longer contains retired layouts.",
1112 "Commit a complete schema-history manifest and pass `--schema-history`.",
1113 )
1114 };
1115 findings.push(finding);
1116 }
1117
1118 match (source_schema, target_schema) {
1119 (Some(from), Some(to)) => {
1120 validate_schema_manifests(source, target, from, to, &mut findings);
1121 compare_storage_schemas(from, to, &mut findings);
1122 }
1123 (None, None) => {
1124 let finding = if policy.require_storage_schema {
1125 error(
1126 "STO000",
1127 "Storage compatibility was not checked",
1128 "Soroban WASM does not contain a complete description of all storage keys and value layouts.",
1129 "Commit complete source and target storage schemas and pass both files.",
1130 )
1131 } else {
1132 warning(
1133 "STO000",
1134 "Storage compatibility was not checked",
1135 "Soroban WASM does not contain a complete description of all storage keys and value layouts.",
1136 "Commit complete source and target storage schemas and pass both files.",
1137 )
1138 };
1139 findings.push(finding);
1140 }
1141 _ => findings.push(error(
1142 "STO004",
1143 "Storage schema pair is incomplete",
1144 "Only one side of the upgrade supplied a storage schema, so compatibility cannot be evaluated.",
1145 "Supply both `--from-schema` and `--to-schema`.",
1146 )),
1147 }
1148
1149 let (public_impacts, impact_limit_exceeded) = trace_public_impacts(source, target);
1150 if impact_limit_exceeded {
1151 findings.push(error(
1152 "RES001",
1153 "Public type-impact analysis exceeded its limit",
1154 "The type graph produced too many paths or too much traversal work for a complete result.",
1155 "Reduce the public type graph or split the contract interface before approval.",
1156 ));
1157 }
1158 findings.sort_by(|a, b| a.severity.cmp(&b.severity).then(a.code.cmp(&b.code)));
1159 let safe = !findings.iter().any(|f| f.severity == Severity::Error);
1160 let evidence = build_evidence_coverage(
1161 context,
1162 source_schema.is_some() && target_schema.is_some(),
1163 schema_history.is_some(),
1164 );
1165 ValidationReport {
1166 format_version: 1,
1167 tool_version: env!("CARGO_PKG_VERSION").into(),
1168 safe,
1169 policy: policy.clone(),
1170 context: context.clone(),
1171 source: source.clone(),
1172 target: target.clone(),
1173 findings,
1174 public_impacts,
1175 evidence,
1176 storage_schema_checked: source_schema.is_some() && target_schema.is_some(),
1177 schema_history_checked: schema_history.is_some(),
1178 policy_sha256: canonical_sha256(policy),
1179 source_schema_sha256: source_schema.map(canonical_sha256),
1180 target_schema_sha256: target_schema.map(canonical_sha256),
1181 schema_history_sha256: schema_history.map(|history| history.source_sha256.clone()),
1182 source_schema: source_schema.cloned(),
1183 target_schema: target_schema.cloned(),
1184 schema_history: schema_history.cloned(),
1185 }
1186}
1187
1188fn check_upgrade_host_capability(
1189 artifact: &Artifact,
1190 side: &str,
1191 code: &str,
1192 findings: &mut Vec<Finding>,
1193) {
1194 if !artifact.has_function("upgrade") {
1195 if side == "source" {
1196 findings.push(error(
1197 code,
1198 "Source has no executable upgrade path",
1199 "The source Contract Spec has no `upgrade` function for the planned replacement.",
1200 "Deploy through an existing authorized replacement path before you use the standard planner.",
1201 ));
1202 }
1203 return;
1204 }
1205 let reaches_update = artifact
1206 .export_call_evidence
1207 .get("upgrade")
1208 .is_some_and(|evidence| {
1209 evidence
1210 .host_imports
1211 .contains(UPDATE_CURRENT_CONTRACT_WASM_IMPORT)
1212 });
1213 if !reaches_update {
1214 findings.push(error(
1215 code,
1216 &format!("{side} upgrade function cannot replace WASM"),
1217 &format!(
1218 "The {side} `upgrade` export does not reach Stellar host import `{UPDATE_CURRENT_CONTRACT_WASM_IMPORT}`."
1219 ),
1220 "Call `update_current_contract_wasm` from the authorized upgrade path and rebuild the exact candidate.",
1221 ));
1222 }
1223}
1224
1225fn canonical_sha256<T: Serialize>(value: &T) -> String {
1226 serde_json::to_vec(value)
1227 .map(|bytes| hex::encode(Sha256::digest(bytes)))
1228 .unwrap_or_default()
1229}
1230
1231fn evidence_item(
1232 status: EvidenceStatus,
1233 claim: &str,
1234 basis: &str,
1235 limitation: Option<&str>,
1236) -> EvidenceItem {
1237 EvidenceItem {
1238 status,
1239 claim: claim.into(),
1240 basis: basis.into(),
1241 limitation: limitation.map(str::to_owned),
1242 }
1243}
1244
1245fn build_evidence_coverage(
1246 context: &ValidationContext,
1247 storage_schema_checked: bool,
1248 schema_history_checked: bool,
1249) -> EvidenceCoverage {
1250 let target_network_protocol = match context.protocol_source {
1251 ProtocolSource::StellarCliNetworkInfo
1252 if context.target_protocol_version.is_some()
1253 && context.network_name.is_some()
1254 && context.network_id.is_some()
1255 && context.observed_at_unix_seconds.is_some() =>
1256 {
1257 evidence_item(
1258 EvidenceStatus::Fact,
1259 "The named network reported the recorded target protocol at the observation time.",
1260 "Live Stellar CLI network-info evidence is embedded in the report.",
1261 Some("Network state can change. Resolve it again immediately before execution."),
1262 )
1263 }
1264 ProtocolSource::OfflineAssertion => evidence_item(
1265 EvidenceStatus::Inference,
1266 "The selected protocol is an offline release assumption.",
1267 "The operator supplied a protocol number without live network evidence.",
1268 Some("This does not prove that any named network has activated the protocol."),
1269 ),
1270 _ => evidence_item(
1271 EvidenceStatus::Unknown,
1272 "The target network protocol is not established.",
1273 "No complete live network evidence is present.",
1274 Some("Resolve a named network before producing an executable release plan."),
1275 ),
1276 };
1277
1278 EvidenceCoverage {
1279 compiled_contract_spec: evidence_item(
1280 EvidenceStatus::Fact,
1281 "The report compares the exact compiled Contract Spec entries in both artifacts.",
1282 "The validator parsed one unambiguous contractspecv0 section from each hashed WASM.",
1283 Some("Contract Spec is not a complete deployed-caller or storage inventory."),
1284 ),
1285 artifact_host_imports: evidence_item(
1286 EvidenceStatus::Fact,
1287 "The report records artifact-wide host imports and direct per-export reachability.",
1288 "The validator inspected the WASM import, export, and code sections.",
1289 Some("Dynamic dispatch makes static reachability incomplete."),
1290 ),
1291 target_network_protocol,
1292 declared_storage_schema: if storage_schema_checked {
1293 evidence_item(
1294 EvidenceStatus::Fact,
1295 "The source-controlled source and target storage declarations were compared.",
1296 "A complete manifest pair was supplied and bound to the validation report.",
1297 Some("A declaration does not prove that it covers every ledger entry."),
1298 )
1299 } else {
1300 evidence_item(
1301 EvidenceStatus::Unknown,
1302 "The declared storage change is not established.",
1303 "No complete source/target manifest pair was supplied.",
1304 Some("Contract Spec alone cannot recover a complete storage inventory."),
1305 )
1306 },
1307 declared_schema_history: if schema_history_checked {
1308 evidence_item(
1309 EvidenceStatus::Fact,
1310 "A cumulative source-controlled field history was checked.",
1311 "The history bytes are hashed into the validation and release plan.",
1312 Some("The history is a reviewed declaration, not proof of ledger-wide migration."),
1313 )
1314 } else {
1315 evidence_item(
1316 EvidenceStatus::Unknown,
1317 "Historical field-name lifecycle is not established.",
1318 "No cumulative schema-history manifest was supplied.",
1319 Some("A two-release diff cannot detect older retired-name reuse."),
1320 )
1321 },
1322 ledger_storage_coverage: evidence_item(
1323 EvidenceStatus::Unknown,
1324 "Complete live and archived ledger storage coverage is not proven.",
1325 "Artifact validation does not sample or enumerate deployed ledger state.",
1326 Some("Use an application-specific snapshot rehearsal before a production migration."),
1327 ),
1328 deployed_caller_graph: evidence_item(
1329 EvidenceStatus::Unknown,
1330 "The complete deployed caller graph and rollout order are not proven.",
1331 "Public impact paths are structural paths inside the two compiled Contract Specs.",
1332 Some("External contracts and off-chain clients can exist outside both artifacts."),
1333 ),
1334 cap0086_per_type_reader_binding: evidence_item(
1335 EvidenceStatus::Unknown,
1336 "A global sparse-reader import is not bound to the changed type.",
1337 "Per-export direct-call reachability narrows the evidence but generated type-level binding is absent.",
1338 Some("Approve schema evolution only after type-specific runtime and rollout evidence."),
1339 ),
1340 migration_completion: evidence_item(
1341 EvidenceStatus::Unknown,
1342 "Ledger-wide migration completion and invariant preservation are not proven.",
1343 "The validator checks declarations but does not execute application migrations.",
1344 Some("Record application-specific rehearsal, completion, and invariant evidence."),
1345 ),
1346 }
1347}
1348
1349fn trace_public_impacts(source: &Artifact, target: &Artifact) -> (Vec<PublicImpact>, bool) {
1350 let changed_types = source
1351 .user_types
1352 .iter()
1353 .filter_map(|(name, before)| {
1354 target
1355 .user_types
1356 .get(name)
1357 .filter(|after| after.canonical != before.canonical)
1358 .map(|_| name.clone())
1359 })
1360 .collect::<BTreeSet<_>>();
1361
1362 let source_boundaries = source
1363 .public_type_boundaries
1364 .iter()
1365 .cloned()
1366 .collect::<BTreeSet<_>>();
1367 let target_boundaries = target
1368 .public_type_boundaries
1369 .iter()
1370 .cloned()
1371 .collect::<BTreeSet<_>>();
1372 let retained_boundaries = source_boundaries
1373 .intersection(&target_boundaries)
1374 .filter(|boundary| {
1375 source
1376 .functions
1377 .get(&boundary.function)
1378 .map(|entry| &entry.canonical)
1379 == target
1380 .functions
1381 .get(&boundary.function)
1382 .map(|entry| &entry.canonical)
1383 })
1384 .cloned()
1385 .collect::<Vec<_>>();
1386
1387 let mut impacts = BTreeSet::new();
1388 let mut limit_exceeded = false;
1389 for boundary in retained_boundaries {
1390 for changed_type in &changed_types {
1391 let (source_routes, source_limited) =
1392 routes_to_type(source, &boundary.root_type, changed_type);
1393 let (target_routes, target_limited) =
1394 routes_to_type(target, &boundary.root_type, changed_type);
1395 limit_exceeded |= source_limited || target_limited;
1396 for steps in source_routes.intersection(&target_routes) {
1397 impacts.insert(PublicImpact {
1398 changed_type: changed_type.clone(),
1399 boundary: boundary.clone(),
1400 steps: steps.clone(),
1401 structural_reachability: EvidenceStatus::Fact,
1402 runtime_compatibility: EvidenceStatus::Unknown,
1403 });
1404 }
1405 }
1406 }
1407 (impacts.into_iter().collect(), limit_exceeded)
1408}
1409
1410fn routes_to_type(
1411 artifact: &Artifact,
1412 root_type: &str,
1413 target_type: &str,
1414) -> (BTreeSet<Vec<ImpactStep>>, bool) {
1415 let mut result = BTreeSet::new();
1416 let mut visited_steps = 0;
1417 let mut limit_exceeded = false;
1418 let mut pending = Vec::new();
1419 if artifact.user_types.contains_key(root_type) {
1420 pending.push((root_type.to_owned(), Vec::new(), BTreeSet::new()));
1421 }
1422
1423 while let Some((current, steps, mut active_types)) = pending.pop() {
1424 visited_steps += 1;
1425 if visited_steps > MAX_PUBLIC_IMPACT_STEPS
1426 || steps.len() > MAX_PUBLIC_IMPACT_DEPTH
1427 || result.len() >= MAX_PUBLIC_IMPACT_PATHS
1428 {
1429 limit_exceeded = true;
1430 break;
1431 }
1432 if current == target_type {
1433 result.insert(steps);
1434 continue;
1435 }
1436 if !active_types.insert(current.clone()) {
1437 continue;
1438 }
1439 for edge in artifact
1440 .type_references
1441 .iter()
1442 .rev()
1443 .filter(|edge| edge.owner_type == current)
1444 {
1445 if pending.len() + visited_steps >= MAX_PUBLIC_IMPACT_STEPS {
1446 limit_exceeded = true;
1447 break;
1448 }
1449 let mut next_steps = steps.clone();
1450 next_steps.push(ImpactStep {
1451 owner_type: edge.owner_type.clone(),
1452 member: edge.member.clone(),
1453 target_type: edge.target_type.clone(),
1454 });
1455 pending.push((edge.target_type.clone(), next_steps, active_types.clone()));
1456 }
1457 if limit_exceeded {
1458 break;
1459 }
1460 }
1461 (result, limit_exceeded)
1462}
1463
1464fn check_policy(policy: &Policy, findings: &mut Vec<Finding>) {
1465 if policy.format_version != 1 {
1466 findings.push(error(
1467 "POL001",
1468 "Unsupported policy format",
1469 &format!(
1470 "Policy `{}` uses format version {}. This build supports version 1.",
1471 policy.name, policy.format_version
1472 ),
1473 "Regenerate the policy with a supported format or upgrade the validator before relying on its result.",
1474 ));
1475 }
1476
1477 let disabled = [
1478 (
1479 !policy.require_upgrade_function,
1480 "retained upgrade entrypoint",
1481 ),
1482 (!policy.require_semver_increase, "increasing `binver`"),
1483 (!policy.require_storage_schema, "complete storage schemas"),
1484 (!policy.require_schema_history, "complete schema history"),
1485 (!policy.deny_removed_functions, "removed public functions"),
1486 (
1487 !policy.deny_changed_functions,
1488 "changed function signatures",
1489 ),
1490 (!policy.deny_removed_events, "removed contract events"),
1491 (!policy.deny_changed_events, "changed contract events"),
1492 (
1493 !policy.deny_changed_user_types,
1494 "changed or removed user-defined types",
1495 ),
1496 ]
1497 .into_iter()
1498 .filter_map(|(is_disabled, label)| is_disabled.then_some(label))
1499 .collect::<Vec<_>>();
1500
1501 if !disabled.is_empty() {
1502 findings.push(warning(
1503 "POL002",
1504 "Policy disables compatibility protections",
1505 &format!(
1506 "Policy `{}` disables checks for: {}.",
1507 policy.name,
1508 disabled.join(", ")
1509 ),
1510 "Use the conservative default or ensure every exception is reviewed and preserved in the plan digest.",
1511 ));
1512 }
1513}
1514
1515fn check_environment_compatibility(
1516 target: &Artifact,
1517 context: &ValidationContext,
1518 findings: &mut Vec<Finding>,
1519) {
1520 if target.env_pre_release != 0 {
1521 findings.push(error(
1522 "ENV001",
1523 "Candidate uses a prerelease host interface",
1524 &format!(
1525 "The candidate declares environment protocol {} with prerelease value {}.",
1526 target.env_protocol_version, target.env_pre_release
1527 ),
1528 "Build the candidate with a stable Soroban SDK before release.",
1529 ));
1530 }
1531
1532 if context
1533 .target_protocol_version
1534 .is_some_and(|protocol| protocol < target.env_protocol_version)
1535 {
1536 findings.push(error(
1537 "ENV002",
1538 "Candidate requires a newer network protocol",
1539 &format!(
1540 "The candidate requires protocol {}, but the selected network evidence reports protocol {}.",
1541 target.env_protocol_version,
1542 context.target_protocol_version.unwrap_or_default()
1543 ),
1544 "Use a compatible SDK or wait for the network protocol upgrade.",
1545 ));
1546 }
1547}
1548
1549fn check_protocol_context(context: &ValidationContext, findings: &mut Vec<Finding>) {
1550 match context.protocol_source {
1551 ProtocolSource::Unpinned if context.target_protocol_version.is_none() => {
1552 findings.push(warning(
1553 "NET001",
1554 "Target protocol was not pinned",
1555 "Protocol-dependent host capabilities cannot be approved without the target network's active protocol version.",
1556 "Pass `--network` for a live Stellar CLI network read or `--protocol-version` for an explicitly recorded offline assertion.",
1557 ));
1558 }
1559 ProtocolSource::Unpinned => findings.push(error(
1560 "NET002",
1561 "Protocol version has no evidence source",
1562 "The validation context contains a protocol version but does not state whether it came from a live network read or an offline assertion.",
1563 "Construct the context through the CLI resolver so provenance is recorded and plan-bound.",
1564 )),
1565 ProtocolSource::OfflineAssertion if context.target_protocol_version.is_some() => {
1566 findings.push(warning(
1567 "NET003",
1568 "Target protocol is an offline assertion",
1569 "The protocol version was supplied by the operator and was not read from live network state during this validation.",
1570 "Re-run with `--network` immediately before release, or preserve independent protocol evidence with the reviewed plan.",
1571 ));
1572 }
1573 ProtocolSource::OfflineAssertion => findings.push(error(
1574 "NET004",
1575 "Offline protocol assertion is empty",
1576 "The context marks its protocol as an offline assertion but contains no protocol version.",
1577 "Supply an explicit protocol version or use a live network read.",
1578 )),
1579 ProtocolSource::StellarCliNetworkInfo
1580 if context.target_protocol_version.is_some()
1581 && context.network_name.is_some()
1582 && context.network_id.is_some()
1583 && context.network_passphrase.is_some()
1584 && context.rpc_version.is_some()
1585 && context.observed_at_unix_seconds.is_some() =>
1586 {
1587 findings.push(info(
1588 "NET005",
1589 "Target protocol resolved from live network state",
1590 &format!(
1591 "Stellar CLI read protocol {} for network `{}` (network ID {}).",
1592 context.target_protocol_version.unwrap_or_default(),
1593 context.network_name.as_deref().unwrap_or("unknown"),
1594 context.network_id.as_deref().unwrap_or("unknown")
1595 ),
1596 "Keep this evidence in the content-addressed plan and resolve it again immediately before submission.",
1597 ));
1598 }
1599 ProtocolSource::StellarCliNetworkInfo => findings.push(error(
1600 "NET006",
1601 "Live network evidence is incomplete",
1602 "The context claims a Stellar CLI network read but is missing protocol, network identity, passphrase, RPC version, or observation time.",
1603 "Discard the incomplete context and repeat the live network query.",
1604 )),
1605 }
1606}
1607
1608fn check_cap_0086(target: &Artifact, context: &ValidationContext, findings: &mut Vec<Finding>) {
1609 let sparse_read = target.uses_cap_0086_sparse_read();
1610 let sparse_write = target.uses_cap_0086_sparse_write();
1611
1612 match context.target_protocol_version {
1613 None => {}
1614 Some(protocol) if (sparse_read || sparse_write) && protocol < CAP_0086_PROTOCOL => {
1615 findings.push(error(
1616 "CAP001",
1617 "Candidate requires CAP-0086 before network activation",
1618 &format!(
1619 "The target imports CAP-0086 sparse-map host functions, which require protocol {CAP_0086_PROTOCOL}, but the selected target protocol is {protocol}."
1620 ),
1621 "Deploy a protocol-27-compatible build or wait until the target network activates protocol 28 and re-run validation.",
1622 ));
1623 }
1624 Some(protocol) if protocol >= CAP_0086_PROTOCOL && sparse_read => {
1625 findings.push(info(
1626 "CAP002",
1627 "Candidate imports CAP-0086 sparse decoding",
1628 &format!(
1629 "The candidate imports `sparse_map_unpack_to_linear_memory` and the selected protocol {protocol} supports it. This artifact-level fact does not prove which contract type uses the sparse reader."
1630 ),
1631 "Require type-specific reader evidence plus field-history and cross-contract rollout checks before approving schema evolution.",
1632 ));
1633 }
1634 Some(protocol) if protocol >= CAP_0086_PROTOCOL => findings.push(warning(
1635 "CAP003",
1636 "Protocol supports CAP-0086 but the candidate does not use sparse decoding",
1637 &format!(
1638 "Protocol {protocol} exposes CAP-0086, but this WASM does not import `sparse_map_unpack_to_linear_memory`. Its contract-type decoding remains strict."
1639 ),
1640 "Use an SDK or explicit implementation that opts into CAP-0086 before treating missing or additional fields as compatible.",
1641 )),
1642 Some(_) => {}
1643 }
1644
1645 if sparse_write && !sparse_read {
1646 findings.push(warning(
1647 "CAP004",
1648 "Sparse writer is enabled without sparse reader",
1649 "Omitting `Void` fields can break older strict readers in cross-contract calls when contracts are upgraded independently.",
1650 "Prefer sparse reads first, keep sparse writes explicitly opt-in, and validate a staged dependency-aware rollout.",
1651 ));
1652 }
1653
1654 let sparse_read_exports = target
1655 .export_call_evidence
1656 .iter()
1657 .filter_map(|(export, evidence)| {
1658 evidence
1659 .host_imports
1660 .contains(CAP_0086_SPARSE_READ_IMPORT)
1661 .then_some(export.as_str())
1662 })
1663 .collect::<Vec<_>>();
1664 let sparse_write_exports = target
1665 .export_call_evidence
1666 .iter()
1667 .filter_map(|(export, evidence)| {
1668 evidence
1669 .host_imports
1670 .contains(CAP_0086_SPARSE_WRITE_IMPORT)
1671 .then_some(export.as_str())
1672 })
1673 .collect::<Vec<_>>();
1674 let dynamic_exports = target
1675 .export_call_evidence
1676 .iter()
1677 .filter_map(|(export, evidence)| {
1678 evidence
1679 .dynamic_dispatch_reachable
1680 .then_some(export.as_str())
1681 })
1682 .collect::<Vec<_>>();
1683
1684 if sparse_read && sparse_read_exports.is_empty() {
1685 findings.push(error(
1686 "CAP007",
1687 "Sparse-reader import is not directly reachable from an exported function",
1688 "The candidate imports `m.c`, but the direct-call graph does not connect that import to any exported function. An unused or dynamically reached import is not evidence that a contract entrypoint decodes sparsely.",
1689 "Provide an artifact whose relevant exported entrypoint directly reaches the sparse reader, and retain the call evidence with the report.",
1690 ));
1691 }
1692 if sparse_write && sparse_write_exports.is_empty() {
1693 findings.push(error(
1694 "CAP009",
1695 "Sparse-writer import is not directly reachable from an exported function",
1696 "The candidate imports `m.b`, but the direct-call graph does not connect that import to any exported function. An unused or dynamically reached import is not evidence that a contract entrypoint writes sparsely.",
1697 "Provide an artifact whose relevant exported entrypoint directly reaches the sparse writer, and retain the call evidence with the report.",
1698 ));
1699 }
1700 if (sparse_read || sparse_write) && !dynamic_exports.is_empty() {
1701 findings.push(warning(
1702 "CAP008",
1703 "Dynamic dispatch limits CAP-0086 call-graph evidence",
1704 &format!(
1705 "Exported function(s) {} reach indirect or reference calls, so static host-import reachability is incomplete even where direct CAP-0086 paths are present.",
1706 dynamic_exports.join(", ")
1707 ),
1708 "Remove dynamic dispatch from the compatibility-critical path or provide a separately verified complete call-target set.",
1709 ));
1710 }
1711}
1712
1713fn validate_schema_manifests(
1714 source: &Artifact,
1715 target: &Artifact,
1716 source_schema: &StorageSchema,
1717 target_schema: &StorageSchema,
1718 findings: &mut Vec<Finding>,
1719) {
1720 for (side, artifact, schema) in [
1721 ("source", source, source_schema),
1722 ("target", target, target_schema),
1723 ] {
1724 if schema.format_version != 1 {
1725 findings.push(error(
1726 "STO006",
1727 "Unsupported storage manifest format",
1728 &format!(
1729 "The {side} manifest uses format version {}. This build supports version 1.",
1730 schema.format_version
1731 ),
1732 "Regenerate the manifest with a supported tool version or upgrade the validator before relying on its result.",
1733 ));
1734 }
1735
1736 if !schema.complete {
1737 findings.push(error(
1738 "STO010",
1739 "Storage schema is not marked complete",
1740 &format!(
1741 "The {side} storage schema does not declare complete coverage of its known storage keys."
1742 ),
1743 "Set `complete` to true only after you include every known storage key and value type.",
1744 ));
1745 }
1746
1747 if schema.schema_version == 0 {
1748 findings.push(error(
1749 "STO011",
1750 "Storage schema version is zero",
1751 &format!("The {side} storage schema must use a positive schema version."),
1752 "Set `schemaVersion` to the version that the contract stores or enforces.",
1753 ));
1754 }
1755
1756 if Version::parse(&schema.contract_version).is_err() {
1757 findings.push(error(
1758 "STO012",
1759 "Storage schema contract version is invalid",
1760 &format!(
1761 "The {side} storage schema uses `{}` as its contract version.",
1762 schema.contract_version
1763 ),
1764 "Use the exact semantic version from the artifact `binver` metadata.",
1765 ));
1766 }
1767
1768 if let Some(artifact_version) = artifact.version() {
1769 if artifact_version != schema.contract_version {
1770 findings.push(error(
1771 "STO007",
1772 "Storage manifest version does not match WASM",
1773 &format!(
1774 "The {side} manifest declares contract version `{}`, but its WASM `binver` is `{artifact_version}`.",
1775 schema.contract_version
1776 ),
1777 "Generate and commit the storage manifest from the same source revision and build as the reviewed WASM.",
1778 ));
1779 }
1780 }
1781
1782 let mut counts = BTreeMap::new();
1783 for entry in &schema.entries {
1784 *counts.entry(&entry.key).or_insert(0_u32) += 1;
1785 }
1786 for (key, count) in counts {
1787 if count > 1 {
1788 findings.push(error(
1789 "STO008",
1790 "Storage manifest contains duplicate keys",
1791 &format!(
1792 "The {side} manifest declares storage key `{key}` {count} times, making comparison ambiguous."
1793 ),
1794 "Keep exactly one declaration for each logical storage key.",
1795 ));
1796 }
1797 }
1798 }
1799
1800 for entry in &target_schema.entries {
1801 let Some(migration) = &entry.migration else {
1802 continue;
1803 };
1804 if migration.strategy.trim().is_empty()
1805 || migration.strategy.len() > 128
1806 || migration.strategy.chars().any(char::is_control)
1807 {
1808 findings.push(error(
1809 "STO013",
1810 "Migration strategy is invalid",
1811 &format!(
1812 "Storage key `{}` has an empty, oversized, or invalid migration strategy.",
1813 entry.key
1814 ),
1815 "Use a short reviewed strategy name without control characters.",
1816 ));
1817 }
1818 let Some(entrypoint) = &migration.entrypoint else {
1819 findings.push(error(
1820 "STO014",
1821 "Migration entrypoint is not declared",
1822 &format!(
1823 "Storage key `{}` has migration data without an entrypoint.",
1824 entry.key
1825 ),
1826 "Name the exported idempotent migration function in the storage declaration.",
1827 ));
1828 continue;
1829 };
1830 if !target.has_function(entrypoint) {
1831 findings.push(error(
1832 "STO009",
1833 "Declared migration entrypoint is missing",
1834 &format!(
1835 "Storage key `{}` declares migration entrypoint `{entrypoint}`, but the target WASM specification does not export it.",
1836 entry.key
1837 ),
1838 "Export the declared migration function or correct the manifest and rehearse the selected strategy.",
1839 ));
1840 }
1841 }
1842}
1843
1844fn validate_schema_history(
1845 source: &Artifact,
1846 target: &Artifact,
1847 history: &SchemaHistory,
1848 findings: &mut Vec<Finding>,
1849) {
1850 if history.format_version != 1 {
1851 findings.push(error(
1852 "HIS001",
1853 "Unsupported schema-history format",
1854 &format!(
1855 "The history manifest uses format version {}. This build supports version 1.",
1856 history.format_version
1857 ),
1858 "Regenerate the history manifest with a supported format before relying on it.",
1859 ));
1860 return;
1861 }
1862 if !history.complete {
1863 findings.push(error(
1864 "HIS012",
1865 "Schema history is not marked complete",
1866 "The history does not declare complete coverage of all known releases and fields.",
1867 "Set `complete` to true only after you reconstruct and review the full release history.",
1868 ));
1869 }
1870
1871 for (type_name, type_history) in &history.types {
1872 for (field_name, record) in &type_history.fields {
1873 let first_seen = Version::parse(&record.first_seen);
1874 let retired_in = record.retired_in.as_deref().map(Version::parse).transpose();
1875 if first_seen.is_err() || retired_in.is_err() {
1876 findings.push(error(
1877 "HIS013",
1878 "History contains an invalid semantic version",
1879 &format!(
1880 "History for `{type_name}.{field_name}` has an invalid `firstSeen` or `retiredIn` value."
1881 ),
1882 "Use exact semantic versions from released `binver` metadata.",
1883 ));
1884 continue;
1885 }
1886 if let (Ok(first_seen), Ok(Some(retired_in))) = (first_seen, retired_in) {
1887 if retired_in < first_seen {
1888 findings.push(error(
1889 "HIS014",
1890 "Field retirement precedes its first release",
1891 &format!(
1892 "History retires `{type_name}.{field_name}` in {retired_in}, before its first release {first_seen}."
1893 ),
1894 "Correct the release versions from attested historical artifacts.",
1895 ));
1896 }
1897 if !type_history.reserved_fields.contains(field_name) {
1898 findings.push(error(
1899 "HIS015",
1900 "Retired field name is not reserved",
1901 &format!(
1902 "History retires `{type_name}.{field_name}` but does not reserve the field name."
1903 ),
1904 "Add every retired field name to `reservedFields` and never reuse it.",
1905 ));
1906 }
1907 }
1908 }
1909 for field_name in &type_history.reserved_fields {
1910 if type_history
1911 .fields
1912 .get(field_name)
1913 .is_none_or(|record| record.retired_in.is_none())
1914 {
1915 findings.push(error(
1916 "HIS016",
1917 "Reserved field has no retirement record",
1918 &format!(
1919 "History reserves `{type_name}.{field_name}` without a matching retired field record."
1920 ),
1921 "Record the historical type and retirement release for each reserved field name.",
1922 ));
1923 }
1924 }
1925 }
1926
1927 let source_version = source.version().unwrap_or("unknown");
1928 let target_version = target.version().unwrap_or("unknown");
1929 let source_types = artifact_struct_fields(source);
1930 let target_types = artifact_struct_fields(target);
1931
1932 for (type_name, fields) in &source_types {
1933 for (field_name, field_type) in fields {
1934 let Some(record) = history
1935 .types
1936 .get(type_name)
1937 .and_then(|type_history| type_history.fields.get(field_name))
1938 else {
1939 findings.push(error(
1940 "HIS002",
1941 "Historical baseline is incomplete",
1942 &format!(
1943 "Source {source_version} contains `{type_name}.{field_name}`, but the cumulative history has no record of it."
1944 ),
1945 "Bootstrap the manifest from all known releases and preserve every historical field before approving an upgrade.",
1946 ));
1947 continue;
1948 };
1949 if record.value_type != *field_type {
1950 findings.push(error(
1951 "HIS003",
1952 "Historical field type does not match the source artifact",
1953 &format!(
1954 "History records `{type_name}.{field_name}` as {}, but source {source_version} contains {}.",
1955 display_json(&record.value_type),
1956 display_json(field_type)
1957 ),
1958 "Correct the history from attested release artifacts. Do not rewrite history to fit the candidate.",
1959 ));
1960 }
1961 }
1962 }
1963
1964 for (type_name, fields) in &target_types {
1965 let Some(type_history) = history.types.get(type_name) else {
1966 findings.push(error(
1967 "HIS004",
1968 "Candidate type is missing from schema history",
1969 &format!(
1970 "Target {target_version} contains struct `{type_name}`, but the cumulative history has no record for the type."
1971 ),
1972 "Add the type and every field with accurate `firstSeen` values in the same reviewed release change.",
1973 ));
1974 continue;
1975 };
1976
1977 for (field_name, field_type) in fields {
1978 if type_history.reserved_fields.contains(field_name) {
1979 findings.push(error(
1980 "HIS005",
1981 "Reserved field name was reused",
1982 &format!(
1983 "Target {target_version} reintroduces reserved field `{type_name}.{field_name}`, creating a historical type-confusion risk."
1984 ),
1985 "Choose a new field name and keep the historical name permanently reserved.",
1986 ));
1987 }
1988
1989 let Some(record) = type_history.fields.get(field_name) else {
1990 findings.push(error(
1991 "HIS006",
1992 "Candidate field is missing from schema history",
1993 &format!(
1994 "Target {target_version} contains `{type_name}.{field_name}`, but the cumulative history was not updated."
1995 ),
1996 "Record the field type and set `firstSeen` to the candidate contract version.",
1997 ));
1998 continue;
1999 };
2000 if record.retired_in.is_some() {
2001 findings.push(error(
2002 "HIS007",
2003 "Retired field was reintroduced",
2004 &format!(
2005 "Target {target_version} contains `{type_name}.{field_name}`, which history marks retired in {}.",
2006 record.retired_in.as_deref().unwrap_or("an earlier release")
2007 ),
2008 "Use a new field name. Never reinterpret a retired key across stored or cross-contract maps.",
2009 ));
2010 }
2011 if record.value_type != *field_type {
2012 findings.push(error(
2013 "HIS008",
2014 "Historical field type changed",
2015 &format!(
2016 "History fixes `{type_name}.{field_name}` as {}, but target {target_version} contains {}.",
2017 display_json(&record.value_type),
2018 display_json(field_type)
2019 ),
2020 "Add a new field and explicit migration rather than changing the meaning or representation of a historical field name.",
2021 ));
2022 }
2023 if !source_types
2024 .get(type_name)
2025 .is_some_and(|source_fields| source_fields.contains_key(field_name))
2026 && record.first_seen != target_version
2027 {
2028 findings.push(error(
2029 "HIS009",
2030 "New field has an incorrect first-seen release",
2031 &format!(
2032 "`{type_name}.{field_name}` first appears in target {target_version}, but history declares `{}`.",
2033 record.first_seen
2034 ),
2035 "Set `firstSeen` to the exact candidate `binver` and review the history change with the WASM.",
2036 ));
2037 }
2038 }
2039 }
2040
2041 for (type_name, source_fields) in &source_types {
2042 let target_fields = target_types.get(type_name);
2043 for field_name in source_fields.keys() {
2044 if target_fields.is_some_and(|fields| fields.contains_key(field_name)) {
2045 continue;
2046 }
2047 let record = history
2048 .types
2049 .get(type_name)
2050 .and_then(|type_history| type_history.fields.get(field_name));
2051 let reserved = history
2052 .types
2053 .get(type_name)
2054 .is_some_and(|type_history| type_history.reserved_fields.contains(field_name));
2055 if record.is_none_or(|record| record.retired_in.as_deref() != Some(target_version))
2056 || !reserved
2057 {
2058 findings.push(error(
2059 "HIS010",
2060 "Removed field is not retired and reserved",
2061 &format!(
2062 "Target {target_version} removes `{type_name}.{field_name}` without recording retirement in this release and permanently reserving the name."
2063 ),
2064 "Keep the field, or record `retiredIn` and add it to `reservedFields`. Prove all migration and reader compatibility assumptions separately.",
2065 ));
2066 } else {
2067 findings.push(warning(
2068 "HIS011",
2069 "Field removal is explicitly retired but remains migration-sensitive",
2070 &format!(
2071 "`{type_name}.{field_name}` is retired and reserved in {target_version}. Archived records or older contracts can still carry it."
2072 ),
2073 "Rehearse archived-state reads and dependency rollout, and never reuse the field name.",
2074 ));
2075 }
2076 }
2077 }
2078}
2079
2080fn artifact_struct_fields(
2081 artifact: &Artifact,
2082) -> BTreeMap<String, BTreeMap<String, serde_json::Value>> {
2083 artifact
2084 .user_types
2085 .iter()
2086 .filter_map(|(name, entry)| struct_fields(entry).map(|fields| (name.clone(), fields)))
2087 .collect()
2088}
2089
2090fn display_json(value: &serde_json::Value) -> String {
2091 serde_json::to_string(value).unwrap_or_else(|_| "<invalid type>".into())
2092}
2093
2094fn check_versions(
2095 source: &Artifact,
2096 target: &Artifact,
2097 policy: &Policy,
2098 findings: &mut Vec<Finding>,
2099) {
2100 if !policy.require_semver_increase {
2101 return;
2102 }
2103 let (Some(from), Some(to)) = (source.version(), target.version()) else {
2104 findings.push(error(
2105 "VER001",
2106 "Missing SEP-49 `binver` metadata",
2107 "Both source and target WASM must embed a semantic version under the `binver` contract metadata key.",
2108 "Build with `stellar contract build --meta binver=<semver>` for both artifacts.",
2109 ));
2110 return;
2111 };
2112 match (Version::parse(from), Version::parse(to)) {
2113 (Ok(from), Ok(to)) if to > from => {}
2114 (Ok(from), Ok(to)) => findings.push(error(
2115 "VER002",
2116 "Target version does not increase",
2117 &format!("Target `binver` {to} must be greater than source `binver` {from}."),
2118 "Use a higher semantic version matching the compatibility impact of the release.",
2119 )),
2120 _ => findings.push(error(
2121 "VER003",
2122 "Invalid semantic version metadata",
2123 &format!("Could not compare source `{from}` with target `{to}` as semantic versions."),
2124 "Use valid SemVer values such as `1.2.0`.",
2125 )),
2126 }
2127}
2128
2129fn compare_interfaces(
2130 source: &Artifact,
2131 target: &Artifact,
2132 policy: &Policy,
2133 context: &ValidationContext,
2134 findings: &mut Vec<Finding>,
2135) {
2136 for (name, old) in &source.functions {
2137 match target.functions.get(name) {
2138 None if policy.deny_removed_functions => findings.push(error(
2139 "ABI001",
2140 "Public function removed",
2141 &format!("The target contract removes `{name}` from the public specification."),
2142 "Preserve the function, introduce a compatibility shim, or document and explicitly approve the breaking change.",
2143 )),
2144 Some(new) if policy.deny_changed_functions && old.canonical != new.canonical => {
2145 findings.push(error(
2146 "ABI002",
2147 "Public function signature changed",
2148 &format!("The inputs, output, or specification of `{name}` changed."),
2149 "Add a new entrypoint and keep the old signature until downstream clients have migrated.",
2150 ));
2151 }
2152 _ => {}
2153 }
2154 }
2155
2156 for (name, old) in &source.events {
2157 match target.events.get(name) {
2158 None if policy.deny_removed_events => findings.push(error(
2159 "EVT001",
2160 "Contract event was removed",
2161 &format!("The target contract removes event `{name}` from the public specification."),
2162 "Keep the event schema or complete a reviewed indexer migration before the release.",
2163 )),
2164 Some(new) if policy.deny_changed_events && old.canonical != new.canonical => {
2165 findings.push(error(
2166 "EVT002",
2167 "Contract event schema changed",
2168 &format!("The topics, parameters, types, or data format of event `{name}` changed."),
2169 "Add a new event name and keep the old schema for existing consumers.",
2170 ));
2171 }
2172 _ => {}
2173 }
2174 }
2175
2176 for (name, old) in &source.user_types {
2177 match target.user_types.get(name) {
2178 None if policy.deny_changed_user_types => findings.push(error(
2179 "ABI003",
2180 "Contract Spec type removed",
2181 &format!(
2182 "The target contract removes the `{name}` {} definition. Contract Spec alone does not identify every public, stored, or cross-contract use of this type.",
2183 old.kind
2184 ),
2185 "Preserve the type or prove every relevant public, storage, and cross-contract migration path before approving removal.",
2186 )),
2187 Some(new) if policy.deny_changed_user_types && old.canonical != new.canonical => {
2188 match optional_struct_fields_added(old, new) {
2189 Some(added)
2190 if context.target_protocol_version.unwrap_or_default()
2191 >= CAP_0086_PROTOCOL
2192 && target.uses_cap_0086_sparse_read() =>
2193 {
2194 findings.push(error(
2195 "CAP005",
2196 "CAP-0086 reader binding is not proven for the changed type",
2197 &format!(
2198 "The `{name}` struct adds only optional field(s): {}. Protocol support and a global sparse-read import are present, but the artifact does not prove that this specific type is decoded through that reader.",
2199 added.join(", ")
2200 ),
2201 "Provide generated type-to-reader evidence and test old/new reader-writer directions against historical state before approving the change.",
2202 ));
2203 }
2204 Some(added) => findings.push(error(
2205 "CAP006",
2206 "Optional field addition is not supported by the selected artifact and protocol",
2207 &format!(
2208 "The `{name}` struct adds optional field(s) {}, but compatibility requires both protocol 28+ and a candidate that imports CAP-0086 sparse decoding.",
2209 added.join(", ")
2210 ),
2211 "Use explicit versioned migration today, or rebuild with CAP-0086 support after activation and re-run validation against the target protocol.",
2212 )),
2213 None => findings.push(error(
2214 "ABI004",
2215 "Contract Spec type changed",
2216 &format!(
2217 "The `{name}` {} definition changed in a way this policy does not classify as compatible. Contract Spec alone does not prove the type's runtime role.",
2218 old.kind
2219 ),
2220 "Introduce a versioned type and migration path instead of mutating the existing definition in place.",
2221 )),
2222 }
2223 }
2224 _ => {}
2225 }
2226 }
2227}
2228
2229fn optional_struct_fields_added(old: &InterfaceEntry, new: &InterfaceEntry) -> Option<Vec<String>> {
2230 if old.kind != "struct" || new.kind != "struct" {
2231 return None;
2232 }
2233 let old_fields = struct_fields(old)?;
2234 let new_fields = struct_fields(new)?;
2235 if new_fields.len() <= old_fields.len()
2236 || old_fields
2237 .iter()
2238 .any(|(name, old_type)| new_fields.get(name) != Some(old_type))
2239 {
2240 return None;
2241 }
2242
2243 let added = new_fields
2244 .iter()
2245 .filter(|(name, _)| !old_fields.contains_key(*name))
2246 .map(|(name, field_type)| is_option_type(field_type).then_some(name.clone()))
2247 .collect::<Option<Vec<_>>>()?;
2248 (!added.is_empty()).then_some(added)
2249}
2250
2251fn struct_fields(entry: &InterfaceEntry) -> Option<BTreeMap<String, serde_json::Value>> {
2252 entry
2253 .canonical
2254 .pointer("/udt_struct_v0/fields")?
2255 .as_array()?
2256 .iter()
2257 .map(|field| {
2258 Some((
2259 field.get("name")?.as_str()?.to_owned(),
2260 field.get("type_")?.clone(),
2261 ))
2262 })
2263 .collect()
2264}
2265
2266fn is_option_type(value: &serde_json::Value) -> bool {
2267 value
2268 .as_object()
2269 .is_some_and(|object| object.contains_key("option"))
2270}
2271
2272fn compare_storage_schemas(
2273 source: &StorageSchema,
2274 target: &StorageSchema,
2275 findings: &mut Vec<Finding>,
2276) {
2277 let old: BTreeMap<_, _> = source.entries.iter().map(|e| (&e.key, e)).collect();
2278 let new: BTreeMap<_, _> = target.entries.iter().map(|e| (&e.key, e)).collect();
2279 let mut layout_changed = false;
2280
2281 for (key, old_entry) in old {
2282 match new.get(key) {
2283 None => {
2284 layout_changed = true;
2285 findings.push(error(
2286 "STO001",
2287 "Storage key removed without a retirement plan",
2288 &format!("Storage key `{key}` is absent from the target schema."),
2289 "Keep the key readable through the migration window or declare and test a versioned retirement strategy.",
2290 ));
2291 }
2292 Some(new_entry)
2293 if old_entry.durability != new_entry.durability
2294 || old_entry.value_type != new_entry.value_type =>
2295 {
2296 layout_changed = true;
2297 if let Some(migration) = &new_entry.migration {
2298 findings.push(warning(
2299 "STO002",
2300 "Storage layout change requires migration",
2301 &format!(
2302 "Storage key `{key}` changes from `{:?}/{}` to `{:?}/{}` and declares `{}` migration.",
2303 old_entry.durability,
2304 old_entry.value_type,
2305 new_entry.durability,
2306 new_entry.value_type,
2307 migration.strategy
2308 ),
2309 "Exercise the migration against a representative pre-upgrade ledger snapshot and verify idempotency.",
2310 ));
2311 } else {
2312 findings.push(error(
2313 "STO003",
2314 "Storage layout changes without migration",
2315 &format!(
2316 "Storage key `{key}` changes from `{:?}/{}` to `{:?}/{}` with no migration declaration.",
2317 old_entry.durability,
2318 old_entry.value_type,
2319 new_entry.durability,
2320 new_entry.value_type
2321 ),
2322 "Declare an eager, lazy, or versioned migration and test it before generating a signer review plan.",
2323 ));
2324 }
2325 }
2326 _ => {}
2327 }
2328 }
2329
2330 if layout_changed && target.schema_version <= source.schema_version {
2331 findings.push(error(
2332 "STO005",
2333 "Schema version was not incremented",
2334 &format!(
2335 "Storage changed but target schema version {} is not greater than source schema version {}.",
2336 target.schema_version, source.schema_version
2337 ),
2338 "Increment `schemaVersion` and guard migration execution with the on-chain schema version.",
2339 ));
2340 }
2341}
2342
2343fn error(code: &str, title: &str, detail: &str, remediation: &str) -> Finding {
2344 Finding {
2345 code: code.into(),
2346 severity: Severity::Error,
2347 title: title.into(),
2348 detail: detail.into(),
2349 remediation: remediation.into(),
2350 }
2351}
2352
2353fn warning(code: &str, title: &str, detail: &str, remediation: &str) -> Finding {
2354 Finding {
2355 code: code.into(),
2356 severity: Severity::Warning,
2357 title: title.into(),
2358 detail: detail.into(),
2359 remediation: remediation.into(),
2360 }
2361}
2362
2363fn info(code: &str, title: &str, detail: &str, remediation: &str) -> Finding {
2364 Finding {
2365 code: code.into(),
2366 severity: Severity::Info,
2367 title: title.into(),
2368 detail: detail.into(),
2369 remediation: remediation.into(),
2370 }
2371}
2372
2373#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
2374#[serde(rename_all = "snake_case")]
2375pub enum PlanStepKind {
2376 VerifyCurrentExecutable,
2377 UploadTargetWasm,
2378 SimulateUpgrade,
2379 ExecuteUpgrade,
2380 ExecuteMigration,
2381 VerifyExecutable,
2382 VerifyInvariants,
2383}
2384
2385#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
2386#[serde(rename_all = "snake_case")]
2387pub enum PlanStatus {
2388 OfflineDraft,
2389 ReviewReady,
2390}
2391
2392#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
2393#[serde(deny_unknown_fields)]
2394pub struct PlanStep {
2395 pub position: u32,
2396 pub kind: PlanStepKind,
2397 pub program: String,
2398 pub arguments: Vec<String>,
2399 pub command: String,
2400 pub expected: String,
2401}
2402
2403#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
2404#[serde(rename_all = "camelCase", deny_unknown_fields)]
2405pub struct MigrationCall {
2406 pub entrypoint: String,
2407 pub arguments: BTreeMap<String, String>,
2408}
2409
2410#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
2411#[serde(rename_all = "camelCase", deny_unknown_fields)]
2412pub struct InvariantCheck {
2413 pub program: String,
2414 pub arguments: Vec<String>,
2415}
2416
2417#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
2418#[serde(rename_all = "camelCase", deny_unknown_fields)]
2419pub struct PlanOperations {
2420 pub migration: Option<MigrationCall>,
2421 pub invariant_check: InvariantCheck,
2422}
2423
2424#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
2425#[serde(rename_all = "camelCase", deny_unknown_fields)]
2426pub struct PlanInputPaths {
2427 pub source_wasm: String,
2428 pub target_wasm: String,
2429 pub source_schema: String,
2430 pub target_schema: String,
2431 pub schema_history: String,
2432 pub policy: Option<String>,
2433}
2434
2435impl PlanStep {
2436 fn new(
2437 position: u32,
2438 kind: PlanStepKind,
2439 program: &str,
2440 arguments: Vec<String>,
2441 expected: String,
2442 ) -> Self {
2443 let command = render_command(program, &arguments);
2444 Self {
2445 position,
2446 kind,
2447 program: program.into(),
2448 arguments,
2449 command,
2450 expected,
2451 }
2452 }
2453}
2454
2455fn render_command(program: &str, arguments: &[String]) -> String {
2456 std::iter::once(program)
2457 .chain(arguments.iter().map(String::as_str))
2458 .map(shell_quote)
2459 .collect::<Vec<_>>()
2460 .join(" ")
2461}
2462
2463fn shell_quote(value: &str) -> String {
2464 if !value.is_empty()
2465 && value
2466 .chars()
2467 .all(|character| character.is_ascii_alphanumeric() || "_+-./:=@".contains(character))
2468 {
2469 value.into()
2470 } else {
2471 format!("'{}'", value.replace('\'', "'\"'\"'"))
2472 }
2473}
2474
2475#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)]
2476#[serde(rename_all = "camelCase", deny_unknown_fields)]
2477pub struct UpgradePlan {
2478 pub format_version: u32,
2479 pub plan_sha256: String,
2480 pub status: PlanStatus,
2481 pub network: String,
2482 pub contract_id: String,
2483 pub source_identity: String,
2484 pub inputs: PlanInputPaths,
2485 pub source_wasm_sha256: String,
2486 pub target_wasm_sha256: String,
2487 pub rollback_wasm_sha256: String,
2488 pub from_version: Option<String>,
2489 pub to_version: Option<String>,
2490 pub migration: Option<MigrationCall>,
2491 pub invariant_check: InvariantCheck,
2492 pub validation: ValidationReport,
2493 pub steps: Vec<PlanStep>,
2494}
2495
2496impl UpgradePlan {
2497 pub fn from_json(bytes: &[u8]) -> Result<Self, Error> {
2498 parse_json_strict(bytes)
2499 }
2500}
2501
2502pub fn create_plan(
2503 report: ValidationReport,
2504 network: &str,
2505 contract_id: &str,
2506 source_identity: &str,
2507 target_wasm_path: &str,
2508 migration_entrypoint: Option<&str>,
2509) -> Result<UpgradePlan, Error> {
2510 let migration = migration_entrypoint.map(|entrypoint| MigrationCall {
2511 entrypoint: entrypoint.into(),
2512 arguments: BTreeMap::from([("operator".into(), source_identity.into())]),
2513 });
2514 create_plan_with_paths(
2515 report,
2516 network,
2517 contract_id,
2518 source_identity,
2519 PlanInputPaths {
2520 source_wasm: "source.wasm".into(),
2521 target_wasm: target_wasm_path.into(),
2522 source_schema: "source.schema.json".into(),
2523 target_schema: "target.schema.json".into(),
2524 schema_history: "schema-history.json".into(),
2525 policy: None,
2526 },
2527 PlanOperations {
2528 migration,
2529 invariant_check: InvariantCheck {
2530 program: "cargo".into(),
2531 arguments: strings(&["test", "--workspace"]),
2532 },
2533 },
2534 )
2535}
2536
2537pub fn create_plan_with_paths(
2538 report: ValidationReport,
2539 network: &str,
2540 contract_id: &str,
2541 source_identity: &str,
2542 inputs: PlanInputPaths,
2543 operations: PlanOperations,
2544) -> Result<UpgradePlan, Error> {
2545 let PlanOperations {
2546 migration,
2547 invariant_check,
2548 } = operations;
2549 validate_plan_input_paths(&inputs)?;
2550 validate_plan_string("network", network, 256)?;
2551 if stellar_strkey::Contract::from_string(contract_id).is_err() {
2552 return Err(Error::InvalidContractId(contract_id.into()));
2553 }
2554
2555 if !report.safe
2556 || report
2557 .findings
2558 .iter()
2559 .any(|finding| finding.severity == Severity::Error)
2560 {
2561 return Err(Error::Plan(
2562 "validation report contains release-blocking findings".into(),
2563 ));
2564 }
2565
2566 if report.context.target_protocol_version.is_none()
2567 || report.context.protocol_source == ProtocolSource::Unpinned
2568 {
2569 return Err(Error::Plan(
2570 "target protocol and its evidence source are not pinned in the validation report"
2571 .into(),
2572 ));
2573 }
2574 match report.context.network_name.as_deref() {
2575 Some(evidence_network) if evidence_network == network => {}
2576 Some(evidence_network) => {
2577 return Err(Error::Plan(format!(
2578 "plan network `{network}` does not match protocol evidence for `{evidence_network}`"
2579 )));
2580 }
2581 None => {
2582 return Err(Error::Plan(
2583 "protocol evidence is not bound to the plan network".into(),
2584 ));
2585 }
2586 }
2587 if !report.storage_schema_checked {
2588 return Err(Error::Plan(
2589 "storage schemas were not checked in the validation report".into(),
2590 ));
2591 }
2592 if !report.schema_history_checked || report.schema_history_sha256.is_none() {
2593 return Err(Error::Plan(
2594 "cumulative schema history was not checked in the validation report".into(),
2595 ));
2596 }
2597
2598 validate_source_identity(source_identity)?;
2599
2600 let storage_migration_required = report
2601 .findings
2602 .iter()
2603 .any(|finding| finding.code == "STO002");
2604 if storage_migration_required && migration.is_none() {
2605 return Err(Error::Plan(
2606 "storage layout changed but no migration entrypoint was selected".into(),
2607 ));
2608 }
2609 if storage_migration_required {
2610 let declared = report
2611 .target_schema
2612 .as_ref()
2613 .into_iter()
2614 .flat_map(|schema| &schema.entries)
2615 .filter_map(|entry| entry.migration.as_ref()?.entrypoint.as_ref())
2616 .cloned()
2617 .collect::<BTreeSet<_>>();
2618 let selected = migration
2619 .as_ref()
2620 .map(|call| BTreeSet::from([call.entrypoint.clone()]))
2621 .unwrap_or_default();
2622 if declared != selected {
2623 return Err(Error::Plan(format!(
2624 "selected migration [{}] does not match declared entrypoints [{}]",
2625 selected.into_iter().collect::<Vec<_>>().join(", "),
2626 declared.into_iter().collect::<Vec<_>>().join(", ")
2627 )));
2628 }
2629 }
2630
2631 validate_standard_upgrade_entrypoint(&report.source)?;
2632 validate_standard_upgrade_entrypoint(&report.target)?;
2633
2634 if let Some(migration) = &migration {
2635 validate_call_arguments(
2636 &report.target,
2637 &migration.entrypoint,
2638 &migration.arguments,
2639 "migration",
2640 )?;
2641 }
2642 validate_invariant_check(&invariant_check)?;
2643
2644 let source_hash = report.source.sha256.clone();
2645 let target_hash = report.target.sha256.clone();
2646 let mut steps = vec![
2647 PlanStep::new(
2648 1,
2649 PlanStepKind::VerifyCurrentExecutable,
2650 "stellar",
2651 strings(&[
2652 "contract",
2653 "fetch",
2654 "--id",
2655 contract_id,
2656 "--network",
2657 network,
2658 "--out-file",
2659 "current.wasm",
2660 ]),
2661 format!("Fetched WASM SHA-256 equals {source_hash}"),
2662 ),
2663 PlanStep::new(
2664 2,
2665 PlanStepKind::UploadTargetWasm,
2666 "stellar",
2667 strings(&[
2668 "contract",
2669 "upload",
2670 "--wasm",
2671 &inputs.target_wasm,
2672 "--optimize=false",
2673 "--source-account",
2674 source_identity,
2675 "--network",
2676 network,
2677 ]),
2678 format!("WASM hash {target_hash}"),
2679 ),
2680 PlanStep::new(
2681 3,
2682 PlanStepKind::SimulateUpgrade,
2683 "stellar",
2684 strings(&[
2685 "contract",
2686 "invoke",
2687 "--id",
2688 contract_id,
2689 "--source-account",
2690 source_identity,
2691 "--network",
2692 network,
2693 "--send",
2694 "no",
2695 "--",
2696 "upgrade",
2697 "--new_wasm_hash",
2698 &target_hash,
2699 "--operator",
2700 source_identity,
2701 ]),
2702 "Successful simulation with expected authorization and resource footprint".into(),
2703 ),
2704 PlanStep::new(
2705 4,
2706 PlanStepKind::ExecuteUpgrade,
2707 "stellar",
2708 strings(&[
2709 "contract",
2710 "invoke",
2711 "--id",
2712 contract_id,
2713 "--source-account",
2714 source_identity,
2715 "--network",
2716 network,
2717 "--",
2718 "upgrade",
2719 "--new_wasm_hash",
2720 &target_hash,
2721 "--operator",
2722 source_identity,
2723 ]),
2724 "Successful executable_update system event".into(),
2725 ),
2726 ];
2727
2728 if let Some(migration) = &migration {
2729 let mut migration_arguments = strings(&[
2730 "contract",
2731 "invoke",
2732 "--id",
2733 contract_id,
2734 "--source-account",
2735 source_identity,
2736 "--network",
2737 network,
2738 "--",
2739 &migration.entrypoint,
2740 ]);
2741 for (name, value) in &migration.arguments {
2742 migration_arguments.push(format!("--{name}"));
2743 migration_arguments.push(value.clone());
2744 }
2745 steps.push(PlanStep::new(
2746 5,
2747 PlanStepKind::ExecuteMigration,
2748 "stellar",
2749 migration_arguments,
2750 "Migration succeeds once and records the new schema version".into(),
2751 ));
2752 }
2753 let next = steps.len() as u32 + 1;
2754 steps.push(PlanStep::new(
2755 next,
2756 PlanStepKind::VerifyExecutable,
2757 "stellar",
2758 strings(&[
2759 "contract",
2760 "fetch",
2761 "--id",
2762 contract_id,
2763 "--network",
2764 network,
2765 "--out-file",
2766 "deployed.wasm",
2767 ]),
2768 format!("Fetched WASM SHA-256 equals {target_hash}"),
2769 ));
2770 steps.push(PlanStep::new(
2771 next + 1,
2772 PlanStepKind::VerifyInvariants,
2773 &invariant_check.program,
2774 invariant_check.arguments.clone(),
2775 "The application-specific post-upgrade invariants pass".into(),
2776 ));
2777
2778 let mut plan = UpgradePlan {
2779 format_version: 3,
2780 plan_sha256: String::new(),
2781 status: if report.context.protocol_source == ProtocolSource::StellarCliNetworkInfo {
2782 PlanStatus::ReviewReady
2783 } else {
2784 PlanStatus::OfflineDraft
2785 },
2786 network: network.into(),
2787 contract_id: contract_id.into(),
2788 source_identity: source_identity.into(),
2789 inputs,
2790 source_wasm_sha256: report.source.sha256.clone(),
2791 target_wasm_sha256: report.target.sha256.clone(),
2792 rollback_wasm_sha256: report.source.sha256.clone(),
2793 from_version: report.source.version().map(str::to_owned),
2794 to_version: report.target.version().map(str::to_owned),
2795 migration,
2796 invariant_check,
2797 validation: report,
2798 steps,
2799 };
2800 let encoded = serde_json::to_string(&plan)?;
2801 if contains_stellar_private_key(&encoded) {
2802 return Err(Error::Plan(
2803 "plan evidence must not contain a Stellar private key".into(),
2804 ));
2805 }
2806 plan.plan_sha256 = calculate_plan_sha256(&plan)?;
2807 Ok(plan)
2808}
2809
2810fn strings(values: &[&str]) -> Vec<String> {
2811 values.iter().map(|value| (*value).into()).collect()
2812}
2813
2814fn calculate_plan_sha256(plan: &UpgradePlan) -> Result<String, Error> {
2815 let mut canonical = serde_json::to_value(plan)?;
2816 let Some(object) = canonical.as_object_mut() else {
2817 return Err(Error::Plan("serialized plan is not a JSON object".into()));
2818 };
2819 object.remove("planSha256");
2820 let bytes = serde_json::to_vec(&canonical)?;
2821 Ok(hex::encode(Sha256::digest(bytes)))
2822}
2823
2824pub fn verify_plan_digest(plan: &UpgradePlan) -> Result<bool, Error> {
2825 validate_plan_structure(plan)?;
2826 Ok(plan.plan_sha256 == calculate_plan_sha256(plan)?)
2827}
2828
2829fn validate_plan_structure(plan: &UpgradePlan) -> Result<(), Error> {
2830 if plan.format_version != 3 {
2831 return Err(Error::Plan(format!(
2832 "unsupported plan format version {}. This build supports version 3",
2833 plan.format_version
2834 )));
2835 }
2836
2837 let revalidated = validate_with_history(
2838 &plan.validation.source,
2839 &plan.validation.target,
2840 plan.validation.source_schema.as_ref(),
2841 plan.validation.target_schema.as_ref(),
2842 &plan.validation.policy,
2843 &plan.validation.context,
2844 plan.validation.schema_history.as_ref(),
2845 );
2846 if revalidated != plan.validation {
2847 return Err(Error::Plan(
2848 "embedded validation report does not match a fresh validation of its evidence".into(),
2849 ));
2850 }
2851
2852 let upload_steps = plan
2853 .steps
2854 .iter()
2855 .filter(|step| step.kind == PlanStepKind::UploadTargetWasm)
2856 .collect::<Vec<_>>();
2857 if upload_steps.len() != 1 {
2858 return Err(Error::Plan(
2859 "plan must contain exactly one target-WASM upload step".into(),
2860 ));
2861 }
2862 let target_wasm_path = argument_after(&upload_steps[0].arguments, "--wasm")
2863 .ok_or_else(|| Error::Plan("upload step has no `--wasm` argument".into()))?;
2864 if target_wasm_path != plan.inputs.target_wasm {
2865 return Err(Error::Plan(
2866 "upload step target does not match the plan input path".into(),
2867 ));
2868 }
2869
2870 let migration_steps = plan
2871 .steps
2872 .iter()
2873 .filter(|step| step.kind == PlanStepKind::ExecuteMigration)
2874 .collect::<Vec<_>>();
2875 if migration_steps.len() > 1 {
2876 return Err(Error::Plan(
2877 "plan contains more than one migration step".into(),
2878 ));
2879 }
2880 if migration_steps.len() != usize::from(plan.migration.is_some()) {
2881 return Err(Error::Plan(
2882 "migration metadata and migration steps do not match".into(),
2883 ));
2884 }
2885
2886 let mut expected = create_plan_with_paths(
2887 plan.validation.clone(),
2888 &plan.network,
2889 &plan.contract_id,
2890 &plan.source_identity,
2891 plan.inputs.clone(),
2892 PlanOperations {
2893 migration: plan.migration.clone(),
2894 invariant_check: plan.invariant_check.clone(),
2895 },
2896 )?;
2897 let mut observed = plan.clone();
2898 expected.plan_sha256.clear();
2899 observed.plan_sha256.clear();
2900 if observed != expected {
2901 return Err(Error::Plan(
2902 "plan fields or commands do not match the canonical validation-derived plan".into(),
2903 ));
2904 }
2905 Ok(())
2906}
2907
2908fn validate_invariant_check(check: &InvariantCheck) -> Result<(), Error> {
2909 if check.program.is_empty()
2910 || check.program.len() > 256
2911 || check
2912 .program
2913 .chars()
2914 .any(|character| character.is_control() || character.is_whitespace())
2915 {
2916 return Err(Error::Plan(
2917 "invariant program must be a non-empty command name without whitespace".into(),
2918 ));
2919 }
2920 if contains_stellar_private_key(&check.program) {
2921 return Err(Error::Plan(
2922 "invariant program must not contain a private key".into(),
2923 ));
2924 }
2925 for argument in &check.arguments {
2926 if argument.len() > 4_096 || argument.chars().any(char::is_control) {
2927 return Err(Error::Plan(
2928 "invariant arguments must not contain control characters or exceed 4096 bytes"
2929 .into(),
2930 ));
2931 }
2932 if contains_stellar_private_key(argument) {
2933 return Err(Error::Plan(
2934 "invariant arguments must not contain private keys".into(),
2935 ));
2936 }
2937 }
2938 Ok(())
2939}
2940
2941fn validate_plan_input_paths(paths: &PlanInputPaths) -> Result<(), Error> {
2942 let required = [
2943 ("source WASM", paths.source_wasm.as_str()),
2944 ("target WASM", paths.target_wasm.as_str()),
2945 ("source schema", paths.source_schema.as_str()),
2946 ("target schema", paths.target_schema.as_str()),
2947 ("schema history", paths.schema_history.as_str()),
2948 ];
2949 for (label, path) in required {
2950 validate_plan_string(&format!("{label} path"), path, 4_096)?;
2951 }
2952 if let Some(path) = &paths.policy {
2953 validate_plan_string("policy path", path, 4_096)?;
2954 }
2955 Ok(())
2956}
2957
2958fn validate_plan_string(label: &str, value: &str, maximum_bytes: usize) -> Result<(), Error> {
2959 if value.is_empty() || value.len() > maximum_bytes || value.chars().any(char::is_control) {
2960 return Err(Error::Plan(format!(
2961 "{label} must be non-empty, contain no control characters, and stay within {maximum_bytes} bytes"
2962 )));
2963 }
2964 if contains_stellar_private_key(value) {
2965 return Err(Error::Plan(format!(
2966 "{label} must not contain a Stellar private key"
2967 )));
2968 }
2969 Ok(())
2970}
2971
2972const STELLAR_SECRET_SEED_LENGTH: usize = 56;
2973
2974fn contains_stellar_private_key(value: &str) -> bool {
2975 value
2976 .as_bytes()
2977 .windows(STELLAR_SECRET_SEED_LENGTH)
2978 .filter(|candidate| candidate[0] == b'S' && candidate.is_ascii())
2979 .filter_map(|candidate| std::str::from_utf8(candidate).ok())
2980 .any(|candidate| {
2981 matches!(
2982 stellar_strkey::Strkey::from_string(candidate),
2983 Ok(stellar_strkey::Strkey::PrivateKeyEd25519(_))
2984 )
2985 })
2986}
2987
2988fn validate_standard_upgrade_entrypoint(target: &Artifact) -> Result<(), Error> {
2989 let arguments = BTreeMap::from([
2990 ("new_wasm_hash".into(), String::new()),
2991 ("operator".into(), String::new()),
2992 ]);
2993 validate_call_arguments(
2994 target,
2995 "upgrade",
2996 &arguments,
2997 "OpenZeppelin-compatible upgrade",
2998 )?;
2999 let inputs = target
3000 .functions
3001 .get("upgrade")
3002 .and_then(|function| function.canonical.pointer("/function_v0/inputs"))
3003 .and_then(serde_json::Value::as_array)
3004 .ok_or_else(|| Error::Plan("cannot read arguments for `upgrade`".into()))?;
3005 let types = inputs
3006 .iter()
3007 .filter_map(|input| Some((input.get("name")?.as_str()?, input.get("type_")?.clone())))
3008 .collect::<BTreeMap<_, _>>();
3009 if types.get("new_wasm_hash") != Some(&serde_json::json!({"bytes_n": {"n": 32}}))
3010 || types.get("operator") != Some(&serde_json::json!("address"))
3011 {
3012 return Err(Error::Plan(
3013 "the `upgrade` entrypoint must use `new_wasm_hash: BytesN<32>` and `operator: Address`"
3014 .into(),
3015 ));
3016 }
3017 let reaches_update = target
3018 .export_call_evidence
3019 .get("upgrade")
3020 .is_some_and(|evidence| {
3021 evidence
3022 .host_imports
3023 .contains(UPDATE_CURRENT_CONTRACT_WASM_IMPORT)
3024 });
3025 if !reaches_update {
3026 return Err(Error::Plan(
3027 "the `upgrade` entrypoint must reach Stellar `update_current_contract_wasm`".into(),
3028 ));
3029 }
3030 Ok(())
3031}
3032
3033fn validate_call_arguments(
3034 target: &Artifact,
3035 entrypoint: &str,
3036 arguments: &BTreeMap<String, String>,
3037 call_kind: &str,
3038) -> Result<(), Error> {
3039 let function = target.functions.get(entrypoint).ok_or_else(|| {
3040 Error::Plan(format!(
3041 "{call_kind} entrypoint `{entrypoint}` is not exported by the target WASM"
3042 ))
3043 })?;
3044 let inputs = function
3045 .canonical
3046 .pointer("/function_v0/inputs")
3047 .and_then(serde_json::Value::as_array)
3048 .ok_or_else(|| Error::Plan(format!("cannot read arguments for `{entrypoint}`")))?;
3049 let expected = inputs
3050 .iter()
3051 .map(|input| {
3052 input
3053 .get("name")
3054 .and_then(serde_json::Value::as_str)
3055 .map(str::to_owned)
3056 .ok_or_else(|| Error::Plan(format!("cannot read an argument for `{entrypoint}`")))
3057 })
3058 .collect::<Result<BTreeSet<_>, _>>()?;
3059 let supplied = arguments.keys().cloned().collect::<BTreeSet<_>>();
3060 if expected != supplied {
3061 return Err(Error::Plan(format!(
3062 "{call_kind} entrypoint `{entrypoint}` requires arguments [{}], but the plan supplies [{}]",
3063 expected.into_iter().collect::<Vec<_>>().join(", "),
3064 supplied.into_iter().collect::<Vec<_>>().join(", ")
3065 )));
3066 }
3067 for value in arguments.values() {
3068 if contains_stellar_private_key(value) {
3069 return Err(Error::Plan(
3070 "plan arguments must not contain private keys".into(),
3071 ));
3072 }
3073 }
3074 Ok(())
3075}
3076
3077fn validate_source_identity(source_identity: &str) -> Result<(), Error> {
3078 if source_identity.is_empty()
3079 || source_identity.len() > 128
3080 || source_identity
3081 .chars()
3082 .any(|character| character.is_control() || character.is_whitespace())
3083 {
3084 return Err(Error::Plan(
3085 "source identity must be a non-empty Stellar CLI alias or public account without whitespace"
3086 .into(),
3087 ));
3088 }
3089 if contains_stellar_private_key(source_identity) {
3090 return Err(Error::Plan(
3091 "source identity must not contain a private key. Use a Stellar CLI alias or public account"
3092 .into(),
3093 ));
3094 }
3095 Ok(())
3096}
3097
3098fn argument_after<'a>(arguments: &'a [String], flag: &str) -> Option<&'a str> {
3099 let position = arguments.iter().position(|argument| argument == flag)?;
3100 arguments.get(position + 1).map(String::as_str)
3101}
3102
3103#[cfg(test)]
3104mod tests {
3105 use super::*;
3106
3107 const TEST_CONTRACT_ID: &str = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4";
3108
3109 fn artifact(version: &str, functions: &[&str]) -> Artifact {
3110 Artifact {
3111 format_version: 1,
3112 sha256: "00".repeat(32),
3113 size_bytes: 1,
3114 env_protocol_version: 27,
3115 env_pre_release: 0,
3116 metadata: BTreeMap::from([("binver".into(), version.into())]),
3117 host_imports: BTreeSet::new(),
3118 functions: functions
3119 .iter()
3120 .map(|name| {
3121 (
3122 (*name).into(),
3123 InterfaceEntry {
3124 kind: "function".into(),
3125 name: (*name).into(),
3126 canonical: match *name {
3127 "upgrade" => serde_json::json!({
3128 "function_v0": {
3129 "name": "upgrade",
3130 "inputs": [
3131 {"name": "new_wasm_hash", "type_": {"bytes_n": {"n": 32}}},
3132 {"name": "operator", "type_": "address"}
3133 ],
3134 "outputs": []
3135 }
3136 }),
3137 "migrate" => serde_json::json!({
3138 "function_v0": {
3139 "name": "migrate",
3140 "inputs": [{"name": "operator", "type_": "address"}],
3141 "outputs": []
3142 }
3143 }),
3144 _ => serde_json::json!({}),
3145 },
3146 },
3147 )
3148 })
3149 .collect(),
3150 events: BTreeMap::new(),
3151 user_types: BTreeMap::new(),
3152 public_type_boundaries: Vec::new(),
3153 type_references: Vec::new(),
3154 export_call_evidence: functions
3155 .iter()
3156 .map(|name| {
3157 let host_imports = if *name == "upgrade" {
3158 BTreeSet::from([UPDATE_CURRENT_CONTRACT_WASM_IMPORT.into()])
3159 } else {
3160 BTreeSet::new()
3161 };
3162 (
3163 (*name).into(),
3164 ExportCallEvidence {
3165 host_imports,
3166 dynamic_dispatch_reachable: false,
3167 },
3168 )
3169 })
3170 .collect(),
3171 }
3172 }
3173
3174 fn schema(version: u32, value_type: &str, migration: Option<Migration>) -> StorageSchema {
3175 StorageSchema {
3176 format_version: 1,
3177 complete: true,
3178 schema_version: version,
3179 contract_version: format!("{version}.0.0"),
3180 entries: vec![StorageEntry {
3181 key: "Config".into(),
3182 durability: Durability::Instance,
3183 value_type: value_type.into(),
3184 migration,
3185 }],
3186 }
3187 }
3188
3189 fn safe_report() -> ValidationReport {
3190 let context = ValidationContext {
3191 target_protocol_version: Some(27),
3192 protocol_source: ProtocolSource::OfflineAssertion,
3193 network_name: Some("testnet".into()),
3194 ..ValidationContext::default()
3195 };
3196 let history = SchemaHistory {
3197 format_version: 1,
3198 complete: true,
3199 types: BTreeMap::new(),
3200 source_sha256: "11".repeat(32),
3201 };
3202 validate_with_history(
3203 &artifact("1.0.0", &["upgrade"]),
3204 &artifact("2.0.0", &["upgrade", "migrate"]),
3205 Some(&schema(1, "u32", None)),
3206 Some(&schema(2, "u32", None)),
3207 &Policy::default(),
3208 &context,
3209 Some(&history),
3210 )
3211 }
3212
3213 fn struct_entry(name: &str, fields: &[(&str, serde_json::Value)]) -> InterfaceEntry {
3214 InterfaceEntry {
3215 kind: "struct".into(),
3216 name: name.into(),
3217 canonical: serde_json::json!({
3218 "udt_struct_v0": {
3219 "name": name,
3220 "lib": "",
3221 "fields": fields
3222 .iter()
3223 .map(|(field_name, field_type)| serde_json::json!({
3224 "name": field_name,
3225 "type_": field_type,
3226 }))
3227 .collect::<Vec<_>>()
3228 }
3229 }),
3230 }
3231 }
3232
3233 #[test]
3234 fn storage_change_without_migration_is_an_error() {
3235 let mut findings = Vec::new();
3236 compare_storage_schemas(
3237 &schema(1, "ConfigV1", None),
3238 &schema(2, "ConfigV2", None),
3239 &mut findings,
3240 );
3241 assert!(findings.iter().any(|f| f.code == "STO003"));
3242 }
3243
3244 #[test]
3245 fn acknowledged_storage_change_is_a_warning() {
3246 let migration = Migration {
3247 strategy: "eager".into(),
3248 entrypoint: Some("migrate".into()),
3249 notes: None,
3250 };
3251 let mut findings = Vec::new();
3252 compare_storage_schemas(
3253 &schema(1, "ConfigV1", None),
3254 &schema(2, "ConfigV2", Some(migration)),
3255 &mut findings,
3256 );
3257 assert!(findings.iter().any(|f| f.code == "STO002"));
3258 assert!(!findings.iter().any(|f| f.severity == Severity::Error));
3259 }
3260
3261 #[test]
3262 fn documentation_is_not_part_of_the_abi_comparison() {
3263 let mut value = serde_json::json!({
3264 "function_v0": {
3265 "doc": "function docs",
3266 "inputs": [{"doc": "argument docs", "name": "value", "type_": "u32"}]
3267 }
3268 });
3269 strip_documentation(&mut value);
3270 assert_eq!(
3271 value,
3272 serde_json::json!({
3273 "function_v0": {
3274 "inputs": [{"name": "value", "type_": "u32"}]
3275 }
3276 })
3277 );
3278 }
3279
3280 #[test]
3281 fn manifest_must_match_wasm_and_export_migration() {
3282 let source = artifact("1.0.0", &["upgrade"]);
3283 let target = artifact("2.0.0", &["upgrade"]);
3284 let mut target_schema = schema(
3285 2,
3286 "ConfigV2",
3287 Some(Migration {
3288 strategy: "eager".into(),
3289 entrypoint: Some("migrate".into()),
3290 notes: None,
3291 }),
3292 );
3293 target_schema.contract_version = "2.0.1".into();
3294 let mut findings = Vec::new();
3295 validate_schema_manifests(
3296 &source,
3297 &target,
3298 &schema(1, "ConfigV1", None),
3299 &target_schema,
3300 &mut findings,
3301 );
3302 assert!(findings.iter().any(|finding| finding.code == "STO007"));
3303 assert!(findings.iter().any(|finding| finding.code == "STO009"));
3304 }
3305
3306 #[test]
3307 fn migration_declaration_requires_a_strategy_and_entrypoint() {
3308 let source = artifact("1.0.0", &["upgrade"]);
3309 let target = artifact("2.0.0", &["upgrade"]);
3310 let target_schema = schema(
3311 2,
3312 "ConfigV2",
3313 Some(Migration {
3314 strategy: " ".into(),
3315 entrypoint: None,
3316 notes: None,
3317 }),
3318 );
3319 let mut findings = Vec::new();
3320 validate_schema_manifests(
3321 &source,
3322 &target,
3323 &schema(1, "ConfigV1", None),
3324 &target_schema,
3325 &mut findings,
3326 );
3327
3328 assert!(findings.iter().any(|finding| finding.code == "STO013"));
3329 assert!(findings.iter().any(|finding| finding.code == "STO014"));
3330 }
3331
3332 #[test]
3333 fn history_versions_and_reservations_are_consistent() {
3334 let history = SchemaHistory {
3335 format_version: 1,
3336 complete: true,
3337 types: BTreeMap::from([(
3338 "OldType".into(),
3339 TypeHistory {
3340 fields: BTreeMap::from([
3341 (
3342 "invalid".into(),
3343 HistoricalField {
3344 value_type: serde_json::json!("u32"),
3345 first_seen: "not-semver".into(),
3346 retired_in: None,
3347 },
3348 ),
3349 (
3350 "late".into(),
3351 HistoricalField {
3352 value_type: serde_json::json!("u32"),
3353 first_seen: "2.0.0".into(),
3354 retired_in: Some("1.0.0".into()),
3355 },
3356 ),
3357 ]),
3358 reserved_fields: BTreeSet::from(["unknown".into()]),
3359 },
3360 )]),
3361 source_sha256: "00".repeat(32),
3362 };
3363 let mut findings = Vec::new();
3364 validate_schema_history(
3365 &artifact("1.0.0", &["upgrade"]),
3366 &artifact("2.0.0", &["upgrade"]),
3367 &history,
3368 &mut findings,
3369 );
3370
3371 for code in ["HIS013", "HIS014", "HIS015", "HIS016"] {
3372 assert!(findings.iter().any(|finding| finding.code == code));
3373 }
3374 }
3375
3376 #[test]
3377 fn event_removal_and_schema_change_are_blocked() {
3378 let mut source = artifact("1.0.0", &["upgrade"]);
3379 source.events.insert(
3380 "transfer".into(),
3381 InterfaceEntry {
3382 kind: "event".into(),
3383 name: "transfer".into(),
3384 canonical: serde_json::json!({"params": ["from", "to"]}),
3385 },
3386 );
3387 let mut changed = artifact("2.0.0", &["upgrade"]);
3388 changed.events.insert(
3389 "transfer".into(),
3390 InterfaceEntry {
3391 kind: "event".into(),
3392 name: "transfer".into(),
3393 canonical: serde_json::json!({"params": ["from", "to", "amount"]}),
3394 },
3395 );
3396 let mut findings = Vec::new();
3397 compare_interfaces(
3398 &source,
3399 &changed,
3400 &Policy::default(),
3401 &ValidationContext::default(),
3402 &mut findings,
3403 );
3404 assert!(findings.iter().any(|finding| finding.code == "EVT002"));
3405
3406 findings.clear();
3407 compare_interfaces(
3408 &source,
3409 &artifact("2.0.0", &["upgrade"]),
3410 &Policy::default(),
3411 &ValidationContext::default(),
3412 &mut findings,
3413 );
3414 assert!(findings.iter().any(|finding| finding.code == "EVT001"));
3415 }
3416
3417 #[test]
3418 fn duplicate_spec_members_are_rejected() {
3419 assert!(matches!(
3420 ensure_unique_spec_members("function", "transfer", ["to".to_owned(), "to".to_owned()],),
3421 Err(Error::DuplicateSpecMember { .. })
3422 ));
3423 }
3424
3425 #[test]
3426 fn plan_digest_detects_any_mutation() {
3427 let mut plan = create_plan(
3428 safe_report(),
3429 "testnet",
3430 TEST_CONTRACT_ID,
3431 "deployer",
3432 "target.wasm",
3433 None,
3434 )
3435 .unwrap();
3436 assert!(verify_plan_digest(&plan).unwrap());
3437
3438 plan.plan_sha256 = "ff".repeat(32);
3439 assert!(!verify_plan_digest(&plan).unwrap());
3440 }
3441
3442 #[test]
3443 fn plan_verification_rejects_recomputed_digest_for_noncanonical_command() {
3444 let mut plan = create_plan(
3445 safe_report(),
3446 "testnet",
3447 TEST_CONTRACT_ID,
3448 "deployer",
3449 "target.wasm",
3450 None,
3451 )
3452 .unwrap();
3453 plan.steps[0].command = "stellar contract upload --wasm substituted.wasm".into();
3454 plan.plan_sha256 = calculate_plan_sha256(&plan).unwrap();
3455
3456 assert!(matches!(
3457 verify_plan_digest(&plan),
3458 Err(Error::Plan(message)) if message.contains("canonical")
3459 ));
3460 }
3461
3462 #[test]
3463 fn plan_verification_rejects_recomputed_digest_for_hash_mismatch() {
3464 let mut plan = create_plan(
3465 safe_report(),
3466 "testnet",
3467 TEST_CONTRACT_ID,
3468 "deployer",
3469 "target.wasm",
3470 None,
3471 )
3472 .unwrap();
3473 plan.target_wasm_sha256 = "ff".repeat(32);
3474 plan.plan_sha256 = calculate_plan_sha256(&plan).unwrap();
3475
3476 assert!(matches!(verify_plan_digest(&plan), Err(Error::Plan(_))));
3477 }
3478
3479 #[test]
3480 fn plan_verification_rejects_a_forged_embedded_report() {
3481 let mut plan = create_plan(
3482 safe_report(),
3483 "testnet",
3484 TEST_CONTRACT_ID,
3485 "deployer",
3486 "target.wasm",
3487 None,
3488 )
3489 .unwrap();
3490 plan.validation.safe = false;
3491 plan.plan_sha256 = calculate_plan_sha256(&plan).unwrap();
3492
3493 assert!(matches!(
3494 verify_plan_digest(&plan),
3495 Err(Error::Plan(message)) if message.contains("fresh validation")
3496 ));
3497 }
3498
3499 #[test]
3500 fn plan_rejects_invalid_contract_id() {
3501 assert!(matches!(
3502 create_plan(
3503 safe_report(),
3504 "testnet",
3505 "C-not-a-contract",
3506 "deployer",
3507 "target.wasm",
3508 None,
3509 ),
3510 Err(Error::InvalidContractId(_))
3511 ));
3512 }
3513
3514 #[test]
3515 fn plan_rejects_private_source_identity() {
3516 let secret = stellar_strkey::ed25519::PrivateKey([7; 32]).to_string();
3517 assert!(matches!(
3518 create_plan(
3519 safe_report(),
3520 "testnet",
3521 TEST_CONTRACT_ID,
3522 &secret,
3523 "target.wasm",
3524 None,
3525 ),
3526 Err(Error::Plan(message)) if message.contains("private key")
3527 ));
3528 }
3529
3530 #[test]
3531 fn private_key_scanner_rejects_a_seed_at_every_byte_offset() {
3532 let secret = stellar_strkey::ed25519::PrivateKey([8; 32]).to_string();
3533 for offset in 0..128 {
3534 let value = format!(
3535 "{}{}{}",
3536 "x".repeat(offset),
3537 secret,
3538 "y".repeat(128 - offset)
3539 );
3540 assert!(
3541 contains_stellar_private_key(&value),
3542 "missed offset {offset}"
3543 );
3544 }
3545 }
3546
3547 #[test]
3548 fn private_key_scanner_handles_unicode_and_near_matches() {
3549 let secret = stellar_strkey::ed25519::PrivateKey([10; 32]).to_string();
3550 assert!(contains_stellar_private_key(&format!(
3551 "blue=🔒{secret}:end"
3552 )));
3553
3554 let mut invalid_checksum = secret.into_bytes();
3555 let last = invalid_checksum.last_mut().unwrap();
3556 *last = if *last == b'A' { b'B' } else { b'A' };
3557 let invalid_checksum = String::from_utf8(invalid_checksum).unwrap();
3558 assert!(!contains_stellar_private_key(&invalid_checksum));
3559 assert!(!contains_stellar_private_key(&"S".repeat(56)));
3560 }
3561
3562 #[test]
3563 fn policy_json_rejects_unknown_fields() {
3564 let json = br#"{
3565 "formatVersion": 1,
3566 "name": "strict",
3567 "requireUpgradeFuncton": false
3568 }"#;
3569
3570 assert!(matches!(Policy::from_json(json), Err(Error::Json(_))));
3571 }
3572
3573 #[test]
3574 fn policy_json_rejects_duplicate_fields() {
3575 let json = br#"{
3576 "formatVersion": 1,
3577 "formatVersion": 1
3578 }"#;
3579
3580 assert!(matches!(Policy::from_json(json), Err(Error::Json(_))));
3581 }
3582
3583 #[test]
3584 fn plan_json_rejects_unbound_unknown_fields() {
3585 let plan = create_plan(
3586 safe_report(),
3587 "testnet",
3588 TEST_CONTRACT_ID,
3589 "deployer",
3590 "target.wasm",
3591 None,
3592 )
3593 .unwrap();
3594 let mut value = serde_json::to_value(plan).unwrap();
3595 value
3596 .as_object_mut()
3597 .unwrap()
3598 .insert("unsignedInstruction".into(), serde_json::json!("approve"));
3599
3600 assert!(matches!(
3601 UpgradePlan::from_json(&serde_json::to_vec(&value).unwrap()),
3602 Err(Error::Json(_))
3603 ));
3604 }
3605
3606 #[test]
3607 fn plan_json_rejects_duplicate_digest_fields() {
3608 let plan = create_plan(
3609 safe_report(),
3610 "testnet",
3611 TEST_CONTRACT_ID,
3612 "deployer",
3613 "target.wasm",
3614 None,
3615 )
3616 .unwrap();
3617 let encoded = serde_json::to_string(&plan).unwrap();
3618 let duplicate = format!(
3619 "{{\"planSha256\":\"{}\",{}",
3620 plan.plan_sha256,
3621 &encoded[1..]
3622 );
3623
3624 assert!(matches!(
3625 UpgradePlan::from_json(duplicate.as_bytes()),
3626 Err(Error::Json(_))
3627 ));
3628 }
3629
3630 #[test]
3631 fn plan_commands_quote_untrusted_arguments_and_preserve_structure() {
3632 let mut report = safe_report();
3633 report.context.network_name = Some("testnet; echo compromised".into());
3634 let plan = create_plan(
3635 report,
3636 "testnet; echo compromised",
3637 TEST_CONTRACT_ID,
3638 "operator",
3639 "target file.wasm",
3640 None,
3641 )
3642 .unwrap();
3643 let upload = plan
3644 .steps
3645 .iter()
3646 .find(|step| step.kind == PlanStepKind::UploadTargetWasm)
3647 .unwrap();
3648 assert_eq!(upload.program, "stellar");
3649 assert_eq!(upload.arguments[3], "target file.wasm");
3650 assert!(upload
3651 .arguments
3652 .iter()
3653 .any(|argument| argument == "--optimize=false"));
3654 assert!(upload.command.contains("'target file.wasm'"));
3655 assert!(upload.command.contains("'testnet; echo compromised'"));
3656 }
3657
3658 #[test]
3659 fn plan_rejects_private_key_in_invariant_arguments() {
3660 let secret = stellar_strkey::ed25519::PrivateKey([9; 32]).to_string();
3661 assert!(matches!(
3662 create_plan_with_paths(
3663 safe_report(),
3664 "testnet",
3665 TEST_CONTRACT_ID,
3666 "deployer",
3667 PlanInputPaths {
3668 source_wasm: "source.wasm".into(),
3669 target_wasm: "target.wasm".into(),
3670 source_schema: "source.schema.json".into(),
3671 target_schema: "target.schema.json".into(),
3672 schema_history: "schema-history.json".into(),
3673 policy: None,
3674 },
3675 PlanOperations {
3676 migration: None,
3677 invariant_check: InvariantCheck {
3678 program: "verify-upgrade".into(),
3679 arguments: vec![secret],
3680 },
3681 },
3682 ),
3683 Err(Error::Plan(message)) if message.contains("private key")
3684 ));
3685 }
3686
3687 #[test]
3688 fn plan_rejects_private_keys_embedded_in_structured_arguments_and_paths() {
3689 let secret = stellar_strkey::ed25519::PrivateKey([11; 32]).to_string();
3690 let report = safe_report();
3691 assert!(matches!(
3692 create_plan_with_paths(
3693 report.clone(),
3694 "testnet",
3695 TEST_CONTRACT_ID,
3696 "deployer",
3697 PlanInputPaths {
3698 source_wasm: "source.wasm".into(),
3699 target_wasm: format!("artifacts/{secret}/target.wasm"),
3700 source_schema: "source.schema.json".into(),
3701 target_schema: "target.schema.json".into(),
3702 schema_history: "schema-history.json".into(),
3703 policy: None,
3704 },
3705 PlanOperations {
3706 migration: None,
3707 invariant_check: InvariantCheck {
3708 program: "verify-upgrade".into(),
3709 arguments: vec!["testnet".into()],
3710 },
3711 },
3712 ),
3713 Err(Error::Plan(message)) if message.contains("private key")
3714 ));
3715
3716 assert!(matches!(
3717 create_plan_with_paths(
3718 report,
3719 "testnet",
3720 TEST_CONTRACT_ID,
3721 "deployer",
3722 PlanInputPaths {
3723 source_wasm: "source.wasm".into(),
3724 target_wasm: "target.wasm".into(),
3725 source_schema: "source.schema.json".into(),
3726 target_schema: "target.schema.json".into(),
3727 schema_history: "schema-history.json".into(),
3728 policy: None,
3729 },
3730 PlanOperations {
3731 migration: None,
3732 invariant_check: InvariantCheck {
3733 program: "verify-upgrade".into(),
3734 arguments: vec![format!(r#"{{"secret":"{secret}"}}"#)],
3735 },
3736 },
3737 ),
3738 Err(Error::Plan(message)) if message.contains("private key")
3739 ));
3740 }
3741
3742 #[test]
3743 fn plan_rejects_private_keys_embedded_in_artifact_evidence() {
3744 let secret = stellar_strkey::ed25519::PrivateKey([12; 32]).to_string();
3745 let mut report = safe_report();
3746 report
3747 .target
3748 .metadata
3749 .insert("operator_hint".into(), format!("ref:{secret}"));
3750
3751 assert!(matches!(
3752 create_plan(
3753 report,
3754 "testnet",
3755 TEST_CONTRACT_ID,
3756 "deployer",
3757 "target.wasm",
3758 None,
3759 ),
3760 Err(Error::Plan(message)) if message.contains("private key")
3761 ));
3762 }
3763
3764 #[test]
3765 fn public_impact_traversal_stops_at_the_depth_limit() {
3766 let mut artifact = artifact("1.0.0", &["read"]);
3767 for index in 0..=MAX_PUBLIC_IMPACT_DEPTH + 1 {
3768 let name = format!("Type{index}");
3769 artifact.user_types.insert(
3770 name.clone(),
3771 InterfaceEntry {
3772 kind: "struct".into(),
3773 name,
3774 canonical: serde_json::json!({}),
3775 },
3776 );
3777 if index <= MAX_PUBLIC_IMPACT_DEPTH {
3778 artifact.type_references.push(TypeReference {
3779 owner_type: format!("Type{index}"),
3780 member: "next".into(),
3781 target_type: format!("Type{}", index + 1),
3782 });
3783 }
3784 }
3785
3786 let (routes, limited) = routes_to_type(
3787 &artifact,
3788 "Type0",
3789 &format!("Type{}", MAX_PUBLIC_IMPACT_DEPTH + 1),
3790 );
3791 assert!(routes.is_empty());
3792 assert!(limited);
3793 }
3794
3795 #[test]
3796 fn named_upgrade_without_host_update_is_blocked() {
3797 let mut candidate = artifact("2.0.0", &["upgrade"]);
3798 candidate
3799 .export_call_evidence
3800 .get_mut("upgrade")
3801 .unwrap()
3802 .host_imports
3803 .clear();
3804 let mut findings = Vec::new();
3805 check_upgrade_host_capability(&candidate, "target", "UPG004", &mut findings);
3806
3807 assert!(findings
3808 .iter()
3809 .any(|finding| { finding.code == "UPG004" && finding.severity == Severity::Error }));
3810 }
3811
3812 #[test]
3813 fn plan_rejects_network_evidence_mismatch() {
3814 assert!(create_plan(
3815 safe_report(),
3816 "mainnet",
3817 TEST_CONTRACT_ID,
3818 "deployer",
3819 "target.wasm",
3820 None,
3821 )
3822 .is_err());
3823 }
3824
3825 #[test]
3826 fn plan_refuses_unsafe_report() {
3827 let mut report = safe_report();
3828 report
3829 .findings
3830 .push(error("ABI001", "removed", "removed function", "restore it"));
3831 assert!(report.safe, "fixture exercises a forged safe flag");
3832 assert!(create_plan(
3833 report,
3834 "testnet",
3835 TEST_CONTRACT_ID,
3836 "deployer",
3837 "target.wasm",
3838 None
3839 )
3840 .is_err());
3841 }
3842
3843 #[test]
3844 fn plan_requires_declared_storage_migration() {
3845 let mut report = safe_report();
3846 report.target_schema.as_mut().unwrap().entries[0].migration = Some(Migration {
3847 strategy: "eager".into(),
3848 entrypoint: Some("migrate".into()),
3849 notes: None,
3850 });
3851 report.findings.push(warning(
3852 "STO002",
3853 "migration required",
3854 "storage changed",
3855 "run migrate",
3856 ));
3857 assert!(create_plan(
3858 report.clone(),
3859 "testnet",
3860 TEST_CONTRACT_ID,
3861 "deployer",
3862 "target.wasm",
3863 None,
3864 )
3865 .is_err());
3866 assert!(create_plan(
3867 report,
3868 "testnet",
3869 TEST_CONTRACT_ID,
3870 "deployer",
3871 "target.wasm",
3872 Some("migrate"),
3873 )
3874 .is_ok());
3875 }
3876
3877 #[test]
3878 fn plan_requires_protocol_storage_and_history_evidence() {
3879 let mut missing_protocol = safe_report();
3880 missing_protocol.context.target_protocol_version = None;
3881 assert!(create_plan(
3882 missing_protocol,
3883 "testnet",
3884 TEST_CONTRACT_ID,
3885 "deployer",
3886 "target.wasm",
3887 None,
3888 )
3889 .is_err());
3890
3891 let mut missing_storage = safe_report();
3892 missing_storage.storage_schema_checked = false;
3893 assert!(create_plan(
3894 missing_storage,
3895 "testnet",
3896 TEST_CONTRACT_ID,
3897 "deployer",
3898 "target.wasm",
3899 None,
3900 )
3901 .is_err());
3902
3903 let mut missing_history = safe_report();
3904 missing_history.schema_history_checked = false;
3905 missing_history.schema_history_sha256 = None;
3906 assert!(create_plan(
3907 missing_history,
3908 "testnet",
3909 TEST_CONTRACT_ID,
3910 "deployer",
3911 "target.wasm",
3912 None,
3913 )
3914 .is_err());
3915 }
3916
3917 #[test]
3918 fn wasm_import_reader_detects_cap_0086_functions() {
3919 let wasm = wat::parse_str(
3920 r#"(module
3921 (type (func (param i64 i64 i64 i64) (result i64)))
3922 (import "m" "b" (func (type 0)))
3923 (import "m" "c" (func (type 0))))"#,
3924 )
3925 .unwrap();
3926 let imports = read_host_imports(&wasm).unwrap();
3927 assert!(imports.contains(CAP_0086_SPARSE_WRITE_IMPORT));
3928 assert!(imports.contains(CAP_0086_SPARSE_READ_IMPORT));
3929 }
3930
3931 #[test]
3932 fn artifact_rejects_duplicate_contract_spec_sections() {
3933 let mut wasm = b"\0asm\x01\0\0\0".to_vec();
3934 for _ in 0..2 {
3935 wasm.extend_from_slice(&[0, 15, 14]);
3936 wasm.extend_from_slice(b"contractspecv0");
3937 }
3938
3939 let error = Artifact::from_wasm(&wasm).unwrap_err();
3940 assert!(matches!(error, Error::ContractSpecSectionCount(2)));
3941 }
3942
3943 #[test]
3944 fn artifact_rejects_input_above_the_parser_limit() {
3945 let oversized = vec![0; MAX_ARTIFACT_SIZE_BYTES + 1];
3946
3947 let error = Artifact::from_wasm(&oversized).unwrap_err();
3948 assert!(matches!(error, Error::ArtifactTooLarge { .. }));
3949 }
3950
3951 #[test]
3952 fn artifact_rejects_duplicate_user_type_names() {
3953 let mut types = BTreeMap::new();
3954 insert_user_type(&mut types, "struct", "State".into(), serde_json::json!({})).unwrap();
3955 let error = insert_user_type(&mut types, "enum", "State".into(), serde_json::json!({}))
3956 .unwrap_err();
3957 assert!(matches!(error, Error::DuplicateSpecName { .. }));
3958 }
3959
3960 #[test]
3961 fn offline_protocol_assertion_is_explicitly_warned() {
3962 let context = ValidationContext {
3963 target_protocol_version: Some(27),
3964 protocol_source: ProtocolSource::OfflineAssertion,
3965 network_name: Some("testnet".into()),
3966 ..ValidationContext::default()
3967 };
3968 let mut findings = Vec::new();
3969 check_protocol_context(&context, &mut findings);
3970
3971 assert!(findings.iter().any(|finding| finding.code == "NET003"));
3972 assert!(!findings
3973 .iter()
3974 .any(|finding| finding.severity == Severity::Error));
3975 }
3976
3977 #[test]
3978 fn complete_live_protocol_evidence_is_accepted() {
3979 let context = ValidationContext {
3980 target_protocol_version: Some(27),
3981 protocol_source: ProtocolSource::StellarCliNetworkInfo,
3982 network_name: Some("testnet".into()),
3983 network_id: Some("network-id".into()),
3984 network_passphrase: Some("Test SDF Network ; September 2015".into()),
3985 rpc_version: Some("27.1.1".into()),
3986 captive_core_version: Some("stellar-core 27.1.0".into()),
3987 observed_at_unix_seconds: Some(1_786_000_000),
3988 };
3989 let mut findings = Vec::new();
3990 check_protocol_context(&context, &mut findings);
3991
3992 assert!(findings.iter().any(|finding| finding.code == "NET005"));
3993 assert!(!findings
3994 .iter()
3995 .any(|finding| finding.severity == Severity::Error));
3996 }
3997
3998 #[test]
3999 fn incomplete_live_protocol_evidence_is_rejected() {
4000 let context = ValidationContext {
4001 target_protocol_version: Some(27),
4002 protocol_source: ProtocolSource::StellarCliNetworkInfo,
4003 network_name: Some("testnet".into()),
4004 ..ValidationContext::default()
4005 };
4006 let mut findings = Vec::new();
4007 check_protocol_context(&context, &mut findings);
4008
4009 assert!(findings.iter().any(|finding| finding.code == "NET006"));
4010 assert!(findings
4011 .iter()
4012 .any(|finding| finding.severity == Severity::Error));
4013 }
4014
4015 #[test]
4016 fn default_policy_blocks_missing_storage_evidence() {
4017 let context = ValidationContext {
4018 target_protocol_version: Some(27),
4019 protocol_source: ProtocolSource::OfflineAssertion,
4020 network_name: Some("testnet".into()),
4021 ..ValidationContext::default()
4022 };
4023 let report = validate_with_history(
4024 &artifact("1.0.0", &["upgrade"]),
4025 &artifact("2.0.0", &["upgrade"]),
4026 None,
4027 None,
4028 &Policy::default(),
4029 &context,
4030 None,
4031 );
4032
4033 assert!(!report.safe);
4034 assert!(report
4035 .findings
4036 .iter()
4037 .any(|finding| { finding.code == "STO000" && finding.severity == Severity::Error }));
4038 assert!(report
4039 .findings
4040 .iter()
4041 .any(|finding| { finding.code == "HIS000" && finding.severity == Severity::Error }));
4042 }
4043
4044 #[test]
4045 fn environment_protocol_and_prerelease_are_release_gates() {
4046 let mut target = artifact("2.0.0", &["upgrade"]);
4047 target.env_protocol_version = 28;
4048 target.env_pre_release = 1;
4049 let context = ValidationContext {
4050 target_protocol_version: Some(27),
4051 protocol_source: ProtocolSource::OfflineAssertion,
4052 network_name: Some("testnet".into()),
4053 ..ValidationContext::default()
4054 };
4055 let mut findings = Vec::new();
4056 check_environment_compatibility(&target, &context, &mut findings);
4057
4058 assert!(findings.iter().any(|finding| finding.code == "ENV001"));
4059 assert!(findings.iter().any(|finding| finding.code == "ENV002"));
4060 }
4061
4062 #[test]
4063 fn protocol_without_provenance_is_rejected() {
4064 let context = ValidationContext {
4065 target_protocol_version: Some(27),
4066 ..ValidationContext::default()
4067 };
4068 let mut findings = Vec::new();
4069 check_protocol_context(&context, &mut findings);
4070
4071 assert!(findings.iter().any(|finding| finding.code == "NET002"));
4072 assert!(findings
4073 .iter()
4074 .any(|finding| finding.severity == Severity::Error));
4075 }
4076
4077 #[test]
4078 fn optional_field_addition_requires_per_type_cap_0086_evidence() {
4079 let mut source = artifact("1.0.0", &["upgrade"]);
4080 let mut target = artifact("2.0.0", &["upgrade"]);
4081 source.user_types.insert(
4082 "Account".into(),
4083 struct_entry("Account", &[("balance", serde_json::json!("i128"))]),
4084 );
4085 target.user_types.insert(
4086 "Account".into(),
4087 struct_entry(
4088 "Account",
4089 &[
4090 ("balance", serde_json::json!("i128")),
4091 (
4092 "status",
4093 serde_json::json!({"option": {"value_type": "u32"}}),
4094 ),
4095 ],
4096 ),
4097 );
4098 target
4099 .host_imports
4100 .insert(CAP_0086_SPARSE_READ_IMPORT.into());
4101
4102 let mut findings = Vec::new();
4103 compare_interfaces(
4104 &source,
4105 &target,
4106 &Policy::default(),
4107 &ValidationContext {
4108 target_protocol_version: Some(28),
4109 ..ValidationContext::default()
4110 },
4111 &mut findings,
4112 );
4113 assert!(findings.iter().any(|finding| finding.code == "CAP005"));
4114 assert!(findings
4115 .iter()
4116 .any(|finding| finding.severity == Severity::Error));
4117 }
4118
4119 #[test]
4120 fn optional_field_addition_is_blocked_without_both_cap_requirements() {
4121 let mut source = artifact("1.0.0", &["upgrade"]);
4122 let mut target = artifact("2.0.0", &["upgrade"]);
4123 source.user_types.insert(
4124 "Account".into(),
4125 struct_entry("Account", &[("balance", serde_json::json!("i128"))]),
4126 );
4127 target.user_types.insert(
4128 "Account".into(),
4129 struct_entry(
4130 "Account",
4131 &[
4132 ("balance", serde_json::json!("i128")),
4133 (
4134 "status",
4135 serde_json::json!({"option": {"value_type": "u32"}}),
4136 ),
4137 ],
4138 ),
4139 );
4140
4141 for context in [
4142 ValidationContext {
4143 target_protocol_version: Some(27),
4144 ..ValidationContext::default()
4145 },
4146 ValidationContext {
4147 target_protocol_version: Some(28),
4148 ..ValidationContext::default()
4149 },
4150 ] {
4151 let mut findings = Vec::new();
4152 compare_interfaces(
4153 &source,
4154 &target,
4155 &Policy::default(),
4156 &context,
4157 &mut findings,
4158 );
4159 assert!(findings.iter().any(|finding| finding.code == "CAP006"));
4160 }
4161 }
4162
4163 #[test]
4164 fn cap_0086_never_approves_field_rename_or_type_change() {
4165 let mut source = artifact("1.0.0", &["upgrade"]);
4166 let mut target = artifact("2.0.0", &["upgrade"]);
4167 source.user_types.insert(
4168 "Account".into(),
4169 struct_entry("Account", &[("balance", serde_json::json!("i128"))]),
4170 );
4171 target.user_types.insert(
4172 "Account".into(),
4173 struct_entry("Account", &[("amount", serde_json::json!("u64"))]),
4174 );
4175 target
4176 .host_imports
4177 .insert(CAP_0086_SPARSE_READ_IMPORT.into());
4178 let mut findings = Vec::new();
4179 compare_interfaces(
4180 &source,
4181 &target,
4182 &Policy::default(),
4183 &ValidationContext {
4184 target_protocol_version: Some(28),
4185 ..ValidationContext::default()
4186 },
4187 &mut findings,
4188 );
4189 assert!(findings.iter().any(|finding| finding.code == "ABI004"));
4190 }
4191
4192 #[test]
4193 fn cap_0086_import_is_blocked_before_protocol_28() {
4194 let mut target = artifact("2.0.0", &["upgrade"]);
4195 target
4196 .host_imports
4197 .insert(CAP_0086_SPARSE_READ_IMPORT.into());
4198 let mut findings = Vec::new();
4199 check_cap_0086(
4200 &target,
4201 &ValidationContext {
4202 target_protocol_version: Some(27),
4203 ..ValidationContext::default()
4204 },
4205 &mut findings,
4206 );
4207 assert!(findings.iter().any(|finding| finding.code == "CAP001"));
4208 }
4209
4210 #[test]
4211 fn cap_0086_requires_export_reachability_and_flags_dynamic_dispatch() {
4212 let mut target = artifact("2.0.0", &["account", "upgrade"]);
4213 target
4214 .host_imports
4215 .insert(CAP_0086_SPARSE_READ_IMPORT.into());
4216 let context = ValidationContext {
4217 target_protocol_version: Some(28),
4218 ..ValidationContext::default()
4219 };
4220
4221 let mut findings = Vec::new();
4222 check_cap_0086(&target, &context, &mut findings);
4223 assert!(findings.iter().any(|finding| finding.code == "CAP007"));
4224
4225 target.export_call_evidence.insert(
4226 "account".into(),
4227 ExportCallEvidence {
4228 host_imports: BTreeSet::from([CAP_0086_SPARSE_READ_IMPORT.into()]),
4229 dynamic_dispatch_reachable: true,
4230 },
4231 );
4232 findings.clear();
4233 check_cap_0086(&target, &context, &mut findings);
4234 assert!(!findings.iter().any(|finding| finding.code == "CAP007"));
4235 assert!(findings.iter().any(|finding| finding.code == "CAP008"));
4236 }
4237
4238 #[test]
4239 fn schema_history_accepts_an_exact_cumulative_record() {
4240 let mut source = artifact("1.0.0", &["upgrade"]);
4241 let mut target = artifact("2.0.0", &["upgrade"]);
4242 source.user_types.insert(
4243 "Account".into(),
4244 struct_entry("Account", &[("balance", serde_json::json!("i128"))]),
4245 );
4246 target.user_types.insert(
4247 "Account".into(),
4248 struct_entry(
4249 "Account",
4250 &[
4251 ("balance", serde_json::json!("i128")),
4252 (
4253 "status",
4254 serde_json::json!({"option": {"value_type": "u32"}}),
4255 ),
4256 ],
4257 ),
4258 );
4259 let history = SchemaHistory {
4260 format_version: 1,
4261 complete: true,
4262 types: BTreeMap::from([(
4263 "Account".into(),
4264 TypeHistory {
4265 fields: BTreeMap::from([
4266 (
4267 "balance".into(),
4268 HistoricalField {
4269 value_type: serde_json::json!("i128"),
4270 first_seen: "1.0.0".into(),
4271 retired_in: None,
4272 },
4273 ),
4274 (
4275 "status".into(),
4276 HistoricalField {
4277 value_type: serde_json::json!({"option": {"value_type": "u32"}}),
4278 first_seen: "2.0.0".into(),
4279 retired_in: None,
4280 },
4281 ),
4282 ]),
4283 reserved_fields: BTreeSet::new(),
4284 },
4285 )]),
4286 source_sha256: "22".repeat(32),
4287 };
4288 let mut findings = Vec::new();
4289 validate_schema_history(&source, &target, &history, &mut findings);
4290 assert!(findings.is_empty());
4291 }
4292
4293 #[test]
4294 fn schema_history_blocks_retired_field_reuse() {
4295 let source = artifact("1.0.0", &["upgrade"]);
4296 let mut target = artifact("2.0.0", &["upgrade"]);
4297 target.user_types.insert(
4298 "Account".into(),
4299 struct_entry("Account", &[("balance", serde_json::json!("u64"))]),
4300 );
4301 let history = SchemaHistory {
4302 format_version: 1,
4303 complete: true,
4304 types: BTreeMap::from([(
4305 "Account".into(),
4306 TypeHistory {
4307 fields: BTreeMap::from([(
4308 "balance".into(),
4309 HistoricalField {
4310 value_type: serde_json::json!("i128"),
4311 first_seen: "0.5.0".into(),
4312 retired_in: Some("1.0.0".into()),
4313 },
4314 )]),
4315 reserved_fields: BTreeSet::from(["balance".into()]),
4316 },
4317 )]),
4318 source_sha256: "33".repeat(32),
4319 };
4320 let mut findings = Vec::new();
4321 validate_schema_history(&source, &target, &history, &mut findings);
4322 assert!(findings.iter().any(|finding| finding.code == "HIS005"));
4323 assert!(findings.iter().any(|finding| finding.code == "HIS007"));
4324 assert!(findings.iter().any(|finding| finding.code == "HIS008"));
4325 }
4326
4327 #[test]
4328 fn removed_field_must_be_retired_and_reserved() {
4329 let mut source = artifact("1.0.0", &["upgrade"]);
4330 let mut target = artifact("2.0.0", &["upgrade"]);
4331 source.user_types.insert(
4332 "Account".into(),
4333 struct_entry("Account", &[("balance", serde_json::json!("i128"))]),
4334 );
4335 target
4336 .user_types
4337 .insert("Account".into(), struct_entry("Account", &[]));
4338 let history = SchemaHistory {
4339 format_version: 1,
4340 complete: true,
4341 types: BTreeMap::from([(
4342 "Account".into(),
4343 TypeHistory {
4344 fields: BTreeMap::from([(
4345 "balance".into(),
4346 HistoricalField {
4347 value_type: serde_json::json!("i128"),
4348 first_seen: "1.0.0".into(),
4349 retired_in: None,
4350 },
4351 )]),
4352 reserved_fields: BTreeSet::new(),
4353 },
4354 )]),
4355 source_sha256: "44".repeat(32),
4356 };
4357 let mut findings = Vec::new();
4358 validate_schema_history(&source, &target, &history, &mut findings);
4359 assert!(findings.iter().any(|finding| finding.code == "HIS010"));
4360 }
4361
4362 #[test]
4363 fn export_call_evidence_separates_directly_reachable_host_functions() {
4364 let wasm = wat::parse_str(
4365 r#"(module
4366 (import "m" "c" (func $sparse))
4367 (import "m" "a" (func $dense))
4368 (func $sparse_wrapper call $sparse)
4369 (func (export "read_sparse") call $sparse_wrapper)
4370 (func (export "read_dense") call $dense)
4371 )"#,
4372 )
4373 .unwrap();
4374
4375 let evidence = inspect_export_call_evidence(&wasm).unwrap();
4376 assert_eq!(
4377 evidence["read_sparse"].host_imports,
4378 BTreeSet::from(["m.c".into()])
4379 );
4380 assert_eq!(
4381 evidence["read_dense"].host_imports,
4382 BTreeSet::from(["m.a".into()])
4383 );
4384 assert!(!evidence["read_sparse"].dynamic_dispatch_reachable);
4385 }
4386
4387 #[test]
4388 fn function_import_inspection_preserves_order_and_duplicates() {
4389 let wasm = wat::parse_str(
4390 r#"(module
4391 (import "m" "c" (func $first))
4392 (import "m" "a" (func $dense))
4393 (import "m" "c" (func $second))
4394 )"#,
4395 )
4396 .unwrap();
4397
4398 let imports = inspect_function_imports(&wasm).unwrap();
4399 assert_eq!(
4400 imports
4401 .iter()
4402 .map(FunctionImport::canonical_name)
4403 .collect::<Vec<_>>(),
4404 ["m.c", "m.a", "m.c"]
4405 );
4406 assert_eq!(
4407 imports
4408 .iter()
4409 .map(|import| import.function_index)
4410 .collect::<Vec<_>>(),
4411 [0, 1, 2]
4412 );
4413 }
4414
4415 #[test]
4416 fn export_call_evidence_flags_dynamic_dispatch() {
4417 let wasm = wat::parse_str(
4418 r#"(module
4419 (type $callback (func))
4420 (func $target)
4421 (table 1 funcref)
4422 (elem (i32.const 0) $target)
4423 (func (export "dispatch")
4424 i32.const 0
4425 call_indirect (type $callback))
4426 )"#,
4427 )
4428 .unwrap();
4429
4430 let evidence = inspect_export_call_evidence(&wasm).unwrap();
4431 assert!(evidence["dispatch"].dynamic_dispatch_reachable);
4432 assert!(evidence["dispatch"].host_imports.is_empty());
4433 }
4434
4435 #[test]
4436 fn changed_nested_type_traces_to_a_retained_public_boundary() {
4437 let mut source = artifact("1.0.0", &["portfolio", "upgrade"]);
4438 let mut target = artifact("2.0.0", &["portfolio", "upgrade"]);
4439 source.user_types.insert(
4440 "Balance".into(),
4441 struct_entry("Balance", &[("amount", serde_json::json!("i128"))]),
4442 );
4443 target.user_types.insert(
4444 "Balance".into(),
4445 struct_entry("Balance", &[("amount", serde_json::json!("u64"))]),
4446 );
4447 for artifact in [&mut source, &mut target] {
4448 artifact.public_type_boundaries.push(PublicTypeBoundary {
4449 function: "portfolio".into(),
4450 position: BoundaryPosition::Output,
4451 index: 0,
4452 label: None,
4453 root_type: "Portfolio".into(),
4454 });
4455 artifact.type_references.extend([
4456 TypeReference {
4457 owner_type: "Portfolio".into(),
4458 member: "position".into(),
4459 target_type: "Position".into(),
4460 },
4461 TypeReference {
4462 owner_type: "Position".into(),
4463 member: "balance".into(),
4464 target_type: "Balance".into(),
4465 },
4466 ]);
4467 artifact.user_types.insert(
4468 "Portfolio".into(),
4469 struct_entry("Portfolio", &[("position", serde_json::json!("Position"))]),
4470 );
4471 artifact.user_types.insert(
4472 "Position".into(),
4473 struct_entry("Position", &[("balance", serde_json::json!("Balance"))]),
4474 );
4475 }
4476
4477 let (impacts, limited) = trace_public_impacts(&source, &target);
4478 assert!(!limited);
4479 assert_eq!(impacts.len(), 1);
4480 assert_eq!(impacts[0].changed_type, "Balance");
4481 assert_eq!(
4482 impacts[0]
4483 .steps
4484 .iter()
4485 .map(|step| step.member.as_str())
4486 .collect::<Vec<_>>(),
4487 ["position", "balance"]
4488 );
4489 assert_eq!(impacts[0].structural_reachability, EvidenceStatus::Fact);
4490 assert_eq!(impacts[0].runtime_compatibility, EvidenceStatus::Unknown);
4491 }
4492
4493 #[test]
4494 fn public_impact_keeps_distinct_fields_that_reach_the_same_type() {
4495 let mut source = artifact("1.0.0", &["portfolio", "upgrade"]);
4496 let mut target = artifact("2.0.0", &["portfolio", "upgrade"]);
4497 source.user_types.insert(
4498 "Balance".into(),
4499 struct_entry("Balance", &[("amount", serde_json::json!("i128"))]),
4500 );
4501 target.user_types.insert(
4502 "Balance".into(),
4503 struct_entry("Balance", &[("amount", serde_json::json!("u64"))]),
4504 );
4505 for artifact in [&mut source, &mut target] {
4506 artifact.public_type_boundaries.push(PublicTypeBoundary {
4507 function: "portfolio".into(),
4508 position: BoundaryPosition::Output,
4509 index: 0,
4510 label: None,
4511 root_type: "Portfolio".into(),
4512 });
4513 artifact.type_references.extend([
4514 TypeReference {
4515 owner_type: "Portfolio".into(),
4516 member: "left".into(),
4517 target_type: "Balance".into(),
4518 },
4519 TypeReference {
4520 owner_type: "Portfolio".into(),
4521 member: "right".into(),
4522 target_type: "Balance".into(),
4523 },
4524 ]);
4525 artifact.user_types.insert(
4526 "Portfolio".into(),
4527 struct_entry(
4528 "Portfolio",
4529 &[
4530 ("left", serde_json::json!("Balance")),
4531 ("right", serde_json::json!("Balance")),
4532 ],
4533 ),
4534 );
4535 }
4536
4537 let (impacts, limited) = trace_public_impacts(&source, &target);
4538 assert!(!limited);
4539 assert_eq!(impacts.len(), 2);
4540 assert_eq!(impacts[0].steps[0].member, "left");
4541 assert_eq!(impacts[1].steps[0].member, "right");
4542 }
4543
4544 #[test]
4545 fn evidence_coverage_distinguishes_live_fact_from_offline_inference() {
4546 let live = build_evidence_coverage(
4547 &ValidationContext {
4548 target_protocol_version: Some(28),
4549 protocol_source: ProtocolSource::StellarCliNetworkInfo,
4550 network_name: Some("testnet".into()),
4551 network_id: Some("network-id".into()),
4552 observed_at_unix_seconds: Some(1),
4553 ..ValidationContext::default()
4554 },
4555 true,
4556 true,
4557 );
4558 assert_eq!(live.target_network_protocol.status, EvidenceStatus::Fact);
4559 assert_eq!(live.ledger_storage_coverage.status, EvidenceStatus::Unknown);
4560
4561 let offline = build_evidence_coverage(
4562 &ValidationContext {
4563 target_protocol_version: Some(28),
4564 protocol_source: ProtocolSource::OfflineAssertion,
4565 ..ValidationContext::default()
4566 },
4567 false,
4568 false,
4569 );
4570 assert_eq!(
4571 offline.target_network_protocol.status,
4572 EvidenceStatus::Inference
4573 );
4574 assert_eq!(
4575 offline.declared_storage_schema.status,
4576 EvidenceStatus::Unknown
4577 );
4578 }
4579}