1use semver::{Version, VersionReq};
4use serde::{Deserialize, Serialize};
5
6use crate::{ArtifactTarget, UpdateError, UpdateResult};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum MobileUpdateAction {
12 SelfReplaceAllowed,
14 StoreUpdateRequired,
16 MdmUpdateRequired,
18 SideloadAssisted,
20 Unsupported,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct MobileUpdateRequest {
27 pub current_version: String,
29 pub available_version: Option<String>,
31 pub cluster_version: String,
33 pub protocol_version: String,
35 pub target: ArtifactTarget,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case", tag = "status")]
42pub enum MobileUpdateDecision {
43 NoUpdate,
45 Available {
47 action: MobileUpdateAction,
49 version: String,
51 },
52 ProtocolBlocked {
54 required: String,
56 current: String,
58 },
59 ClusterTooOld {
61 minimum: String,
63 current: String,
65 },
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct MobileUpdatePolicy {
71 pub action: MobileUpdateAction,
73 pub minimum_cluster_version: String,
75 pub required_protocol_version: String,
77}
78
79impl MobileUpdatePolicy {
80 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 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 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}