Skip to main content

soroban_upgrades_core/
lib.rs

1//! Upgrade-safety primitives for Soroban contracts.
2//!
3//! The crate inspects the metadata and contract specification embedded in
4//! Soroban WASM binaries. It never signs or submits a transaction.
5
6use 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
538// Documentation changes do not alter the callable ABI. Keeping them in the
539// comparison would turn harmless comment edits into release-blocking findings.
540fn 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}