spin_locked_app/
locked.rs

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