spin_locked_app/
locked.rs

1//! Spin lock file (spin.lock) serialization models.
2
3use std::{collections::HashSet, path::PathBuf};
4
5use itertools::Itertools;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use spin_serde::{DependencyName, FixedVersionBackwardCompatible};
9use std::collections::BTreeMap;
10
11use crate::{
12    metadata::MetadataExt,
13    values::{ValuesMap, ValuesMapBuilder},
14};
15
16/// A String-keyed map with deterministic serialization order.
17pub type LockedMap<T> = std::collections::BTreeMap<String, T>;
18
19/// If present and required in `host_requirements`, the host must support
20/// local service chaining (*.spin.internal) or reject the app.
21pub const SERVICE_CHAINING_KEY: &str = "local_service_chaining";
22
23/// Indicates that a host feature is optional. This is the default and is
24/// equivalent to omitting the feature from `host_requirements`.
25pub const HOST_REQ_OPTIONAL: &str = "optional";
26/// Indicates that a host feature is required.
27pub const HOST_REQ_REQUIRED: &str = "required";
28
29// TODO: it turns out that using an enum for this results in bad
30// errors by non-understanders (unknown variant rather than "I'm sorry
31// Dave I can't do that")
32/// Identifies fields in the LockedApp that the host must process if present.
33#[derive(Clone, Debug, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum MustUnderstand {
36    /// If present in `must_understand`, the host must support all features
37    /// in the app's `host_requirements` section.
38    HostRequirements,
39    /// If present in `must_understand`, the host must support all features
40    /// in components' `host_requirements` section.
41    ComponentHostRequirements,
42}
43
44/// A LockedApp represents a "fully resolved" Spin application.
45#[derive(Clone, Debug, Deserialize)]
46pub struct LockedApp {
47    /// Locked schema version
48    pub spin_lock_version: FixedVersionBackwardCompatible<1>,
49    /// Identifies fields in the LockedApp that the host must process if present.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub must_understand: Vec<MustUnderstand>,
52    /// Application metadata
53    #[serde(default, skip_serializing_if = "ValuesMap::is_empty")]
54    pub metadata: ValuesMap,
55    /// Host requirements
56    #[serde(
57        default,
58        skip_serializing_if = "ValuesMap::is_empty",
59        deserialize_with = "deserialize_host_requirements"
60    )]
61    pub host_requirements: ValuesMap,
62    /// Custom config variables
63    #[serde(default, skip_serializing_if = "LockedMap::is_empty")]
64    pub variables: LockedMap<Variable>,
65    /// Application triggers
66    pub triggers: Vec<LockedTrigger>,
67    /// Application components
68    pub components: Vec<LockedComponent>,
69}
70
71fn deserialize_host_requirements<'de, D>(deserializer: D) -> Result<ValuesMap, D::Error>
72where
73    D: serde::Deserializer<'de>,
74{
75    struct HostRequirementsVisitor;
76    impl<'de> serde::de::Visitor<'de> for HostRequirementsVisitor {
77        type Value = ValuesMap;
78
79        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
80            formatter.write_str("struct ValuesMap")
81        }
82
83        fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
84        where
85            A: serde::de::MapAccess<'de>,
86        {
87            use serde::de::Error;
88
89            let mut hr = ValuesMapBuilder::new();
90
91            while let Some(key) = map.next_key::<String>()? {
92                let value: serde_json::Value = map.next_value()?;
93                if value.as_str() == Some(HOST_REQ_OPTIONAL) {
94                    continue;
95                }
96
97                hr.serializable(key, value).map_err(A::Error::custom)?;
98            }
99
100            Ok(hr.build())
101        }
102    }
103    let m = deserializer.deserialize_map(HostRequirementsVisitor)?;
104    let unsupported: Vec<_> = m
105        .keys()
106        .filter(|k| !SUPPORTED_HOST_REQS.contains(&k.as_str()))
107        .map(|k| k.to_string())
108        .collect();
109    if unsupported.is_empty() {
110        Ok(m)
111    } else {
112        let msg = format!("This version of Spin does not support the following features required by this application: {}", unsupported.join(", "));
113        Err(serde::de::Error::custom(msg))
114    }
115}
116
117const SUPPORTED_HOST_REQS: &[&str] = &[SERVICE_CHAINING_KEY];
118
119impl Serialize for LockedApp {
120    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
121    where
122        S: serde::Serializer,
123    {
124        use serde::ser::SerializeStruct;
125
126        let version = if self.must_understand.is_empty() && self.host_requirements.is_empty() {
127            0
128        } else {
129            1
130        };
131
132        let mut la = serializer.serialize_struct("LockedApp", 7)?;
133        la.serialize_field("spin_lock_version", &version)?;
134        if !self.must_understand.is_empty() {
135            la.serialize_field("must_understand", &self.must_understand)?;
136        }
137        if !self.metadata.is_empty() {
138            la.serialize_field("metadata", &self.metadata)?;
139        }
140        if !self.host_requirements.is_empty() {
141            la.serialize_field("host_requirements", &self.host_requirements)?;
142        }
143        if !self.variables.is_empty() {
144            la.serialize_field("variables", &self.variables)?;
145        }
146        la.serialize_field("triggers", &self.triggers)?;
147        la.serialize_field("components", &self.components)?;
148        la.end()
149    }
150}
151
152impl LockedApp {
153    /// Deserializes a [`LockedApp`] from the given JSON data.
154    pub fn from_json(contents: &[u8]) -> serde_json::Result<Self> {
155        serde_json::from_slice(contents)
156    }
157
158    /// Serializes the [`LockedApp`] into JSON data.
159    pub fn to_json(&self) -> serde_json::Result<Vec<u8>> {
160        serde_json::to_vec_pretty(&self)
161    }
162
163    /// Deserializes typed metadata for this app.
164    ///
165    /// Returns `Ok(None)` if there is no metadata for the given `key` and an
166    /// `Err` only if there _is_ a value for the `key` but the typed
167    /// deserialization failed.
168    pub fn get_metadata<'this, T: Deserialize<'this>>(
169        &'this self,
170        key: crate::MetadataKey<T>,
171    ) -> crate::Result<Option<T>> {
172        self.metadata.get_typed(key)
173    }
174
175    /// Deserializes typed metadata for this app.
176    ///
177    /// Like [`LockedApp::get_metadata`], but returns an error if there is
178    /// no metadata for the given `key`.
179    pub fn require_metadata<'this, T: Deserialize<'this>>(
180        &'this self,
181        key: crate::MetadataKey<T>,
182    ) -> crate::Result<T> {
183        self.metadata.require_typed(key)
184    }
185
186    /// Checks that the application does not have any host requirements
187    /// outside the supported set. The error case returns a comma-separated
188    /// list of unmet requirements.
189    pub fn ensure_needs_only(&self, trigger_type: &str, supported: &[&str]) -> Result<(), String> {
190        let app_host_requirements = self.host_requirements.keys();
191
192        let component_ids = self
193            .triggers
194            .iter()
195            .filter(|t| t.trigger_type == trigger_type)
196            .flat_map(|t| t.trigger_config.get("component"))
197            .filter_map(|v| v.as_str())
198            .collect::<HashSet<_>>();
199        let components = self
200            .components
201            .iter()
202            .filter(|c| component_ids.contains(c.id.as_str()));
203        let component_host_requirements = components.flat_map(|c| c.host_requirements.keys());
204
205        let all_host_requirements = app_host_requirements.chain(component_host_requirements);
206
207        let unmet_requirements = all_host_requirements
208            .unique()
209            .filter(|hr| !supported.contains(&hr.as_str()))
210            .map(|s| s.to_string())
211            .collect::<Vec<_>>();
212        if unmet_requirements.is_empty() {
213            Ok(())
214        } else {
215            let message = unmet_requirements.join(", ");
216            Err(message)
217        }
218    }
219}
220
221/// A LockedComponent represents a "fully resolved" Spin component.
222#[derive(Clone, Debug, Serialize, Deserialize)]
223pub struct LockedComponent {
224    /// Application-unique component identifier
225    pub id: String,
226    /// Component metadata
227    #[serde(default, skip_serializing_if = "ValuesMap::is_empty")]
228    pub metadata: ValuesMap,
229    /// Wasm source
230    pub source: LockedComponentSource,
231    /// WASI environment variables
232    #[serde(default, skip_serializing_if = "LockedMap::is_empty")]
233    pub env: LockedMap<String>,
234    /// WASI filesystem contents
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub files: Vec<ContentPath>,
237    /// Custom config values
238    #[serde(default, skip_serializing_if = "LockedMap::is_empty")]
239    pub config: LockedMap<String>,
240    /// Component dependencies
241    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
242    pub dependencies: BTreeMap<DependencyName, LockedComponentDependency>,
243    /// Host requirements
244    #[serde(
245        default,
246        skip_serializing_if = "ValuesMap::is_empty",
247        deserialize_with = "deserialize_host_requirements"
248    )]
249    pub host_requirements: ValuesMap,
250}
251
252/// A LockedDependency represents a "fully resolved" Spin component dependency.
253#[derive(Clone, Debug, Serialize, Deserialize)]
254pub struct LockedComponentDependency {
255    /// Locked dependency source
256    pub source: LockedComponentSource,
257    /// The specific export to use from the dependency, if any.
258    pub export: Option<String>,
259    /// Which configurations to inherit from parent
260    #[serde(default, skip_serializing_if = "InheritConfiguration::is_none")]
261    pub inherit: InheritConfiguration,
262}
263
264/// InheritConfiguration specifies which configurations to inherit from parent.
265#[derive(Clone, Debug, Serialize, Deserialize)]
266pub enum InheritConfiguration {
267    /// Dependencies will inherit all configurations from parent.
268    All,
269    /// Dependencies will inherit only the specified configurations from parent
270    /// (if empty then deny-all is enforced).
271    Some(Vec<String>),
272}
273
274impl Default for InheritConfiguration {
275    fn default() -> Self {
276        InheritConfiguration::Some(vec![])
277    }
278}
279
280impl InheritConfiguration {
281    fn is_none(&self) -> bool {
282        matches!(self, InheritConfiguration::Some(configs) if configs.is_empty())
283    }
284}
285
286/// A LockedComponentSource specifies a Wasm source.
287#[derive(Clone, Debug, Serialize, Deserialize)]
288pub struct LockedComponentSource {
289    /// Wasm source content type (e.g. "application/wasm")
290    pub content_type: String,
291    /// Wasm source content specification
292    #[serde(flatten)]
293    pub content: ContentRef,
294}
295
296/// A ContentPath specifies content mapped to a WASI path.
297#[derive(Clone, Debug, Serialize, Deserialize)]
298pub struct ContentPath {
299    /// Content specification
300    #[serde(flatten)]
301    pub content: ContentRef,
302    /// WASI mount path
303    pub path: PathBuf,
304}
305
306/// A ContentRef represents content used by an application.
307///
308/// At least one of `source`, `inline`, or `digest` must be specified. Implementations may
309/// require one or the other (or both).
310#[derive(Clone, Debug, Default, Serialize, Deserialize)]
311pub struct ContentRef {
312    /// A URI where the content can be accessed. Implementations may support
313    /// different URI schemes.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub source: Option<String>,
316    /// The content itself, base64-encoded.
317    ///
318    /// NOTE: This is both an optimization for small content and a workaround
319    /// for certain OCI implementations that don't support 0 or 1 byte blobs.
320    #[serde(
321        default,
322        skip_serializing_if = "Option::is_none",
323        with = "spin_serde::base64"
324    )]
325    pub inline: Option<Vec<u8>>,
326    /// If set, the content must have the given SHA-256 digest.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub digest: Option<String>,
329}
330
331/// A LockedTrigger specifies configuration for an application trigger.
332#[derive(Clone, Debug, Serialize, Deserialize)]
333pub struct LockedTrigger {
334    /// Application-unique trigger identifier
335    pub id: String,
336    /// Trigger type (e.g. "http")
337    pub trigger_type: String,
338    /// Trigger-type-specific configuration
339    pub trigger_config: Value,
340}
341
342/// A Variable specifies a custom configuration variable.
343#[derive(Clone, Debug, Serialize, Deserialize)]
344pub struct Variable {
345    /// The variable's default value. If unset, the variable is required.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub default: Option<String>,
348    /// If set, the variable's value may be sensitive and e.g. shouldn't be logged.
349    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
350    pub secret: bool,
351}
352
353#[cfg(test)]
354mod test {
355    use super::*;
356
357    use crate::values::ValuesMapBuilder;
358
359    #[test]
360    fn locked_app_with_no_host_reqs_serialises_as_v0_and_v0_deserialises_as_v1() {
361        let locked_app = LockedApp {
362            spin_lock_version: Default::default(),
363            must_understand: Default::default(),
364            metadata: Default::default(),
365            host_requirements: Default::default(),
366            variables: Default::default(),
367            triggers: Default::default(),
368            components: Default::default(),
369        };
370
371        let json = locked_app.to_json().unwrap();
372
373        assert!(String::from_utf8_lossy(&json).contains(r#""spin_lock_version": 0"#));
374
375        let reloaded = LockedApp::from_json(&json).unwrap();
376
377        assert_eq!(1, Into::<usize>::into(reloaded.spin_lock_version));
378    }
379
380    #[test]
381    fn locked_app_with_host_reqs_serialises_as_v1() {
382        let mut host_requirements = ValuesMapBuilder::new();
383        host_requirements.string(SERVICE_CHAINING_KEY, "bar");
384        let host_requirements = host_requirements.build();
385
386        let locked_app = LockedApp {
387            spin_lock_version: Default::default(),
388            must_understand: vec![MustUnderstand::HostRequirements],
389            metadata: Default::default(),
390            host_requirements,
391            variables: Default::default(),
392            triggers: Default::default(),
393            components: Default::default(),
394        };
395
396        let json = locked_app.to_json().unwrap();
397
398        assert!(String::from_utf8_lossy(&json).contains(r#""spin_lock_version": 1"#));
399
400        let reloaded = LockedApp::from_json(&json).unwrap();
401
402        assert_eq!(1, Into::<usize>::into(reloaded.spin_lock_version));
403        assert_eq!(1, reloaded.must_understand.len());
404        assert_eq!(1, reloaded.host_requirements.len());
405    }
406
407    #[test]
408    fn deserialising_ignores_unknown_fields() {
409        use serde_json::json;
410        let j = serde_json::to_vec_pretty(&json!({
411            "spin_lock_version": 1,
412            "triggers": [],
413            "components": [],
414            "never_create_field_with_this_name": 123
415        }))
416        .unwrap();
417        let locked = LockedApp::from_json(&j).unwrap();
418        assert_eq!(0, locked.triggers.len());
419    }
420
421    #[test]
422    fn deserialising_does_not_ignore_must_understand_unknown_fields() {
423        use serde_json::json;
424        let j = serde_json::to_vec_pretty(&json!({
425            "spin_lock_version": 1,
426            "must_understand": vec!["never_create_field_with_this_name"],
427            "triggers": [],
428            "components": [],
429            "never_create_field_with_this_name": 123
430        }))
431        .unwrap();
432        let err = LockedApp::from_json(&j).expect_err(
433            "Should have refused to deserialise due to non-understood must-understand field",
434        );
435        assert!(err
436            .to_string()
437            .contains("never_create_field_with_this_name"));
438    }
439
440    #[test]
441    fn deserialising_accepts_must_understands_that_it_does_understand() {
442        use serde_json::json;
443        let j = serde_json::to_vec_pretty(&json!({
444            "spin_lock_version": 1,
445            "must_understand": vec!["host_requirements"],
446            "host_requirements": {
447                SERVICE_CHAINING_KEY: HOST_REQ_REQUIRED,
448            },
449            "triggers": [],
450            "components": [],
451            "never_create_field_with_this_name": 123
452        }))
453        .unwrap();
454        let locked = LockedApp::from_json(&j).unwrap();
455        assert_eq!(1, locked.must_understand.len());
456        assert_eq!(1, locked.host_requirements.len());
457    }
458
459    #[test]
460    fn deserialising_rejects_host_requirements_that_are_not_supported() {
461        use serde_json::json;
462        let j = serde_json::to_vec_pretty(&json!({
463            "spin_lock_version": 1,
464            "must_understand": vec!["host_requirements"],
465            "host_requirements": {
466                SERVICE_CHAINING_KEY: HOST_REQ_REQUIRED,
467                "accelerated_spline_reticulation": HOST_REQ_REQUIRED
468            },
469            "triggers": [],
470            "components": []
471        }))
472        .unwrap();
473        let err = LockedApp::from_json(&j).expect_err(
474            "Should have refused to deserialise due to non-understood host requirement",
475        );
476        assert!(err.to_string().contains("accelerated_spline_reticulation"));
477    }
478
479    #[test]
480    fn deserialising_skips_optional_host_requirements() {
481        use serde_json::json;
482        let j = serde_json::to_vec_pretty(&json!({
483            "spin_lock_version": 1,
484            "must_understand": vec!["host_requirements"],
485            "host_requirements": {
486                SERVICE_CHAINING_KEY: HOST_REQ_REQUIRED,
487                "accelerated_spline_reticulation": HOST_REQ_OPTIONAL
488            },
489            "triggers": [],
490            "components": []
491        }))
492        .unwrap();
493        let locked = LockedApp::from_json(&j).unwrap();
494        assert_eq!(1, locked.must_understand.len());
495        assert_eq!(1, locked.host_requirements.len());
496    }
497}