Skip to main content

appcore_update/
mobile.rs

1//! Managed mobile update policy and compatibility gates.
2
3use semver::{Version, VersionReq};
4use serde::{Deserialize, Serialize};
5
6use crate::{ArtifactTarget, UpdateError, UpdateResult};
7
8/// Mobile update action owned by the deployment or platform distribution.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum MobileUpdateAction {
12    /// The application may replace its own artifact under deployment policy.
13    SelfReplaceAllowed,
14    /// The user must update through the platform store.
15    StoreUpdateRequired,
16    /// The managed device administrator must authorize the update.
17    MdmUpdateRequired,
18    /// A deployment-assisted sideload path may be presented by the owner.
19    SideloadAssisted,
20    /// No supported update path exists for this target.
21    Unsupported,
22}
23
24/// Request context used to evaluate one mobile update offer.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct MobileUpdateRequest {
27    /// Currently installed application version.
28    pub current_version: String,
29    /// Candidate application version, when one is available.
30    pub available_version: Option<String>,
31    /// Cluster version currently serving the application.
32    pub cluster_version: String,
33    /// Protocol used by the installed application.
34    pub protocol_version: String,
35    /// Platform target selected by the deployment.
36    pub target: ArtifactTarget,
37}
38
39/// Result of the bounded mobile update compatibility gate.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case", tag = "status")]
42pub enum MobileUpdateDecision {
43    /// No newer compatible candidate is available.
44    NoUpdate,
45    /// A newer candidate is available through the declared action.
46    Available {
47        /// Action the owning platform/deployment must perform.
48        action: MobileUpdateAction,
49        /// Candidate version to announce.
50        version: String,
51    },
52    /// The installed client must update before protocol use continues.
53    ProtocolBlocked {
54        /// Protocol required by the deployment.
55        required: String,
56        /// Protocol currently presented by the client.
57        current: String,
58    },
59    /// The connected cluster is below the policy minimum.
60    ClusterTooOld {
61        /// Minimum cluster requirement.
62        minimum: String,
63        /// Current cluster version.
64        current: String,
65    },
66}
67
68/// Explicit mobile update policy with no store or installer implementation.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct MobileUpdatePolicy {
71    /// Platform/deployment-owned action for a compatible candidate.
72    pub action: MobileUpdateAction,
73    /// Minimum cluster semantic-version requirement.
74    pub minimum_cluster_version: String,
75    /// Exact protocol required by the deployed Runtime.
76    pub required_protocol_version: String,
77}
78
79impl MobileUpdatePolicy {
80    /// Creates a policy after validating action and compatibility bounds.
81    pub fn new(
82        action: MobileUpdateAction,
83        minimum_cluster_version: impl Into<String>,
84        required_protocol_version: impl Into<String>,
85    ) -> UpdateResult<Self> {
86        let policy = Self {
87            action,
88            minimum_cluster_version: minimum_cluster_version.into(),
89            required_protocol_version: required_protocol_version.into(),
90        };
91        policy.validate()?;
92        Ok(policy)
93    }
94
95    /// Validates policy metadata without contacting a store or device manager.
96    pub fn validate(&self) -> UpdateResult<()> {
97        VersionReq::parse(&self.minimum_cluster_version).map_err(|error| {
98            UpdateError::Incompatible(format!("invalid mobile cluster requirement: {error}"))
99        })?;
100        validate_protocol(&self.required_protocol_version)
101    }
102
103    /// Evaluates one candidate while preserving platform-owned update actions.
104    pub fn evaluate(&self, request: &MobileUpdateRequest) -> UpdateResult<MobileUpdateDecision> {
105        self.validate()?;
106        request.target_validate()?;
107        validate_version("current_version", &request.current_version)?;
108        validate_version("cluster_version", &request.cluster_version)?;
109        validate_protocol(&request.protocol_version)?;
110        if request.protocol_version != self.required_protocol_version {
111            return Ok(MobileUpdateDecision::ProtocolBlocked {
112                required: self.required_protocol_version.clone(),
113                current: request.protocol_version.clone(),
114            });
115        }
116        let cluster = Version::parse(&request.cluster_version).map_err(|error| {
117            UpdateError::Incompatible(format!("invalid mobile cluster version: {error}"))
118        })?;
119        let minimum = VersionReq::parse(&self.minimum_cluster_version).map_err(|error| {
120            UpdateError::Incompatible(format!("invalid mobile cluster requirement: {error}"))
121        })?;
122        if !minimum.matches(&cluster) {
123            return Ok(MobileUpdateDecision::ClusterTooOld {
124                minimum: self.minimum_cluster_version.clone(),
125                current: request.cluster_version.clone(),
126            });
127        }
128        let Some(version) = &request.available_version else {
129            return Ok(MobileUpdateDecision::NoUpdate);
130        };
131        validate_version("available_version", version)?;
132        let offered = Version::parse(version).map_err(|error| {
133            UpdateError::Incompatible(format!("invalid mobile available version: {error}"))
134        })?;
135        let current = Version::parse(&request.current_version).map_err(|error| {
136            UpdateError::Incompatible(format!("invalid mobile current version: {error}"))
137        })?;
138        if offered <= current {
139            return Ok(MobileUpdateDecision::NoUpdate);
140        }
141        Ok(MobileUpdateDecision::Available {
142            action: self.action,
143            version: version.clone(),
144        })
145    }
146}
147
148trait MobileTargetValidation {
149    fn target_validate(&self) -> UpdateResult<()>;
150}
151
152impl MobileTargetValidation for MobileUpdateRequest {
153    fn target_validate(&self) -> UpdateResult<()> {
154        if self.target.os().trim().is_empty()
155            || self.target.architecture().trim().is_empty()
156            || self.target.format().trim().is_empty()
157        {
158            return Err(UpdateError::InvalidArtifact(
159                "mobile target fields must be present".to_owned(),
160            ));
161        }
162        Ok(())
163    }
164}
165
166fn validate_protocol(value: &str) -> UpdateResult<()> {
167    if value.is_empty() || value.len() > 32 || value.chars().any(char::is_control) {
168        return Err(UpdateError::Incompatible(
169            "mobile protocol version is invalid".to_owned(),
170        ));
171    }
172    Ok(())
173}
174
175fn validate_version(name: &str, value: &str) -> UpdateResult<()> {
176    if value.len() > 64 {
177        return Err(UpdateError::Incompatible(format!("{name} is too long")));
178    }
179    Version::parse(value)
180        .map(|_| ())
181        .map_err(|error| UpdateError::Incompatible(format!("invalid {name}: {error}")))
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn request(protocol: &str, cluster: &str, available: Option<&str>) -> MobileUpdateRequest {
189        MobileUpdateRequest {
190            current_version: "1.0.0".to_owned(),
191            available_version: available.map(ToOwned::to_owned),
192            cluster_version: cluster.to_owned(),
193            protocol_version: protocol.to_owned(),
194            target: ArtifactTarget::new("ios", "arm64", "store").unwrap(),
195        }
196    }
197
198    #[test]
199    fn announces_newer_offer_without_recommending_a_bypass() {
200        let policy =
201            MobileUpdatePolicy::new(MobileUpdateAction::StoreUpdateRequired, ">=1.4.0", "1")
202                .unwrap();
203        assert_eq!(
204            policy
205                .evaluate(&request("1", "1.5.0", Some("1.1.0")))
206                .unwrap(),
207            MobileUpdateDecision::Available {
208                action: MobileUpdateAction::StoreUpdateRequired,
209                version: "1.1.0".to_owned(),
210            }
211        );
212    }
213
214    #[test]
215    fn blocks_old_protocol_and_cluster_before_offer() {
216        let policy =
217            MobileUpdatePolicy::new(MobileUpdateAction::SelfReplaceAllowed, ">=2.0.0", "2")
218                .unwrap();
219        assert!(matches!(
220            policy.evaluate(&request("1", "2.0.0", Some("2.0.0"))),
221            Ok(MobileUpdateDecision::ProtocolBlocked { .. })
222        ));
223        assert!(matches!(
224            policy.evaluate(&request("2", "1.9.0", Some("2.0.0"))),
225            Ok(MobileUpdateDecision::ClusterTooOld { .. })
226        ));
227    }
228}