1use std::io::IsTerminal;
2
3use anyhow::{Context, Result, anyhow};
4use semver::{Version, VersionReq};
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8#[derive(Serialize, Debug, Deserialize, PartialEq)]
12#[serde(rename_all = "camelCase")]
13pub struct PluginManifest {
14 name: String,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
18 description: Option<String>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 homepage: Option<String>,
22 pub(crate) version: String,
24 pub(crate) spin_compatibility: String,
26 license: String,
28 pub(crate) packages: Vec<PluginPackage>,
30}
31
32impl PluginManifest {
33 pub fn name(&self) -> String {
34 self.name.to_lowercase()
35 }
36 pub fn version(&self) -> &str {
37 &self.version
38 }
39 pub fn license(&self) -> &str {
40 self.license.as_ref()
41 }
42
43 pub fn spin_compatibility(&self) -> String {
44 self.spin_compatibility.clone()
45 }
46
47 pub fn description(&self) -> Option<&str> {
48 self.description.as_deref()
49 }
50
51 pub fn homepage_url(&self) -> Option<Url> {
52 Url::parse(self.homepage.as_deref()?).ok()
53 }
54
55 pub fn has_compatible_package(&self) -> bool {
56 self.packages.iter().any(|p| p.matches_current_os_arch())
57 }
58
59 pub fn is_compatible_spin_version(&self, spin_version: &str) -> bool {
60 is_version_compatible_enough(&self.spin_compatibility, spin_version).unwrap_or(false)
61 }
62
63 pub fn try_version(&self) -> Result<semver::Version, semver::Error> {
64 semver::Version::parse(&self.version)
65 }
66
67 pub fn compare_versions(&self, other: &Self) -> Option<std::cmp::Ordering> {
69 if let Ok(this_version) = self.try_version()
70 && let Ok(other_version) = other.try_version()
71 {
72 return Some(this_version.cmp_precedence(&other_version));
73 }
74 None
75 }
76
77 pub fn get_package(&self) -> Result<&PluginPackage> {
79 use std::env::consts::{ARCH, OS};
80 self.packages
81 .iter()
82 .find(|p| p.os.rust_name() == OS && p.arch.rust_name() == ARCH)
83 .ok_or_else(|| {
84 anyhow!("This plugin does not support this OS ({OS}) or architecture ({ARCH}).")
85 })
86 }
87}
88
89#[derive(Serialize, Debug, Deserialize, PartialEq)]
91pub struct PluginPackage {
92 pub(crate) os: Os,
94 pub(crate) arch: Architecture,
96 pub(crate) url: String,
98 pub(crate) sha256: String,
100}
101
102impl PluginPackage {
103 pub fn url(&self) -> String {
104 self.url.clone()
105 }
106 pub fn matches_current_os_arch(&self) -> bool {
107 self.os.rust_name() == std::env::consts::OS
108 && self.arch.rust_name() == std::env::consts::ARCH
109 }
110}
111
112#[derive(Serialize, Debug, Deserialize, PartialEq)]
114#[serde(rename_all = "camelCase")]
115pub(crate) enum Os {
116 Linux,
117 Macos,
118 Windows,
119}
120
121impl Os {
122 pub(crate) fn rust_name(&self) -> &'static str {
125 match self {
126 Os::Linux => "linux",
127 Os::Macos => "macos",
128 Os::Windows => "windows",
129 }
130 }
131}
132
133#[derive(Serialize, Debug, Deserialize, PartialEq)]
135#[serde(rename_all = "camelCase")]
136pub(crate) enum Architecture {
137 Amd64,
138 Aarch64,
139 Arm,
140}
141
142impl Architecture {
143 pub(crate) fn rust_name(&self) -> &'static str {
146 match self {
147 Architecture::Amd64 => "x86_64",
148 Architecture::Aarch64 => "aarch64",
149 Architecture::Arm => "arm",
150 }
151 }
152}
153
154pub fn warn_unsupported_version(
157 manifest: &PluginManifest,
158 spin_version: &str,
159 override_compatibility_check: bool,
160) -> Result<()> {
161 let supported_on = &manifest.spin_compatibility;
162 inner_warn_unsupported_version(supported_on, spin_version, override_compatibility_check)
163}
164
165fn is_version_fully_compatible(supported_on: &str, spin_version: &str) -> Result<bool> {
168 let comparator = VersionReq::parse(supported_on).with_context(|| {
169 format!("Could not parse manifest compatibility version {supported_on} as valid semver")
170 })?;
171 let version = Version::parse(spin_version)?;
172 Ok(comparator.matches(&version))
173}
174
175pub(crate) fn is_version_compatible_enough(supported_on: &str, spin_version: &str) -> Result<bool> {
180 if is_version_fully_compatible(supported_on, spin_version)? {
181 Ok(true)
182 } else {
183 let is_spin_prerelease = Version::parse(spin_version)
186 .map(|v| !v.pre.is_empty())
187 .unwrap_or_default();
188 Ok(is_spin_prerelease)
189 }
190}
191
192fn inner_warn_unsupported_version(
193 supported_on: &str,
194 spin_version: &str,
195 override_compatibility_check: bool,
196) -> Result<()> {
197 if !is_version_fully_compatible(supported_on, spin_version)? {
198 let show_warnings = !suppress_compatibility_warnings();
199 let version = Version::parse(spin_version)?;
200 if !version.pre.is_empty() {
201 if std::io::stderr().is_terminal() && show_warnings {
202 terminal::warn!(
203 "You're using a pre-release version of Spin ({spin_version}). This plugin might not be compatible (supported: {supported_on}). Continuing anyway."
204 );
205 }
206 } else if override_compatibility_check {
207 if show_warnings {
208 terminal::warn!(
209 "Plugin is not compatible with this version of Spin (supported: {supported_on}, actual: {spin_version}). Check overridden ... continuing to install or execute plugin."
210 );
211 }
212 } else {
213 return Err(anyhow!(
214 "Plugin is not compatible with this version of Spin (supported: {supported_on}, actual: {spin_version}). Try running `spin plugins update && spin plugins upgrade --all` to install latest or override with `--override-compatibility-check`."
215 ));
216 }
217 }
218 Ok(())
219}
220
221fn suppress_compatibility_warnings() -> bool {
222 match std::env::var("SPIN_PLUGINS_SUPPRESS_COMPATIBILITY_WARNINGS") {
223 Ok(s) => !s.is_empty(),
224 Err(_) => false,
225 }
226}
227
228#[cfg(test)]
229mod test {
230 use super::*;
231
232 fn generate_test_manifest(
233 name: &str,
234 version: &str,
235 license: &str,
236 description: Option<&str>,
237 homepage: Option<&str>,
238 ) -> PluginManifest {
239 let mut plugin_json = serde_json::json!(
240 {
241 "name": name,
242 "version": version,
243 "spinCompatibility": "=0.4",
244 "license": license,
245 "packages": [
246 {
247 "os": "linux",
248 "arch": "amd64",
249 "url": "www.example.com/releases/1.0/binary.tgz",
250 "sha256": "c474f00b12345e38acae2d19b2a707a4fhdjdfdd22875efeefdf052ce19c90b"
251 },
252 {
253 "os": "windows",
254 "arch": "amd64",
255 "url": "www.example.com/releases/1.0/binary.tgz",
256 "sha256": "eee4f00b12345e38acae2d19b2a707a4fhdjdfdd22875efeefdf052ce19c90b"
257 },
258 {
259 "os": "macos",
260 "arch": "aarch64",
261 "url": "www.example.com/releases/1.0/binary.tgz",
262 "sha256": "eeegf00b12345e38acae2d19b2a707a4fhdjdfdd22875efeefdf052ce19c90b"
263 }
264 ]
265 });
266 if let Some(homepage) = homepage {
267 plugin_json
268 .as_object_mut()
269 .unwrap()
270 .insert("homepage".to_string(), serde_json::json!(homepage));
271 }
272 if let Some(description) = description {
273 plugin_json
274 .as_object_mut()
275 .unwrap()
276 .insert("description".to_string(), serde_json::json!(description));
277 }
278 serde_json::from_value(plugin_json).unwrap()
279 }
280
281 #[test]
282 fn test_supported_version() {
283 let test_case = ">=1.2.3, <1.8.0";
284 let input_output = [
285 ("1.3.0", true),
286 ("1.2.3", true),
287 ("1.8.0", false),
288 ("1.9.0", false),
289 ("1.2.0", false),
290 ];
291 input_output.into_iter().for_each(|(i, o)| {
292 assert_eq!(
293 inner_warn_unsupported_version(test_case, i, false).is_ok(),
294 o
295 )
296 });
297 }
298
299 #[test]
300 fn test_plugin_json() {
301 let name = "test";
302 let description = "Some description.";
303 let homepage = "www.example.com";
304 let version = "1.0";
305 let license = "Mit";
306 let deserialized_plugin =
307 generate_test_manifest(name, version, license, Some(description), Some(homepage));
308 assert_eq!(deserialized_plugin.name(), name.to_owned());
309 assert_eq!(
310 deserialized_plugin.description,
311 Some(description.to_owned())
312 );
313 assert_eq!(deserialized_plugin.homepage, Some(homepage.to_owned()));
314 assert_eq!(deserialized_plugin.version, version.to_owned());
315 assert_eq!(deserialized_plugin.license, license.to_owned());
316 assert_eq!(deserialized_plugin.packages.len(), 3);
317 }
318
319 #[test]
320 fn test_plugin_json_empty_options() {
321 let deserialized_plugin = generate_test_manifest("name", "0.1.1", "Mit", None, None);
322 assert_eq!(deserialized_plugin.description, None);
323 assert_eq!(deserialized_plugin.homepage, None);
324 }
325}