Skip to main content

host_product_view/
preflight.rs

1use serde::Serialize;
2
3use crate::{
4    grant_store::{SandboxGrantStore, SandboxGrantStoreError},
5    manifest::{
6        ManifestError, PreparedProductBundle, ProductConsentSummary, ProductManifest, ProductSource,
7    },
8    permissions::{EffectiveSandboxPermissions, SandboxGrant, SandboxPermissionResolution},
9};
10
11/// Shared host-side context for preflighting a verified product bundle before load.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ProductPreflightContext {
14    pub bundle_hash_or_cid: String,
15    pub source: ProductSource,
16    /// `None` means the host cannot yet evaluate chain support and wants to skip
17    /// the required-chain denial step for this load attempt.
18    pub supported_chain_genesis_hashes: Option<Vec<String>>,
19}
20
21/// High-level allow/prompt/deny outcome for a verified bundle.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub enum ProductPreflightOutcome {
24    Allow {
25        effective_permissions: EffectiveSandboxPermissions,
26    },
27    Deny {
28        reason: ProductPreflightDenyReason,
29    },
30    Prompt(Box<ProductPreflightPrompt>),
31}
32
33/// Host-visible prompt payload derived from a verified manifest.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35pub struct ProductPreflightPrompt {
36    pub summary: ProductConsentSummary,
37    pub proposed_grant: SandboxGrant,
38    pub deny_behavior: ConsentDenyBehavior,
39}
40
41/// What the host should do if the user denies the prompt.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43pub enum ConsentDenyBehavior {
44    LoadInStrictSandbox,
45    DenyLoad,
46}
47
48/// User decision returned from a host-owned consent UI.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ProductConsentDecision {
51    Approve,
52    Deny,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56pub enum ProductPreflightDenyReason {
57    UnsupportedRequiredChains { genesis_hashes: Vec<String> },
58    RequiredSandboxConsentDenied,
59}
60
61#[derive(Debug, thiserror::Error)]
62pub enum ProductPreflightError {
63    #[error(transparent)]
64    Manifest(#[from] ManifestError),
65
66    #[error(transparent)]
67    GrantStore(#[from] SandboxGrantStoreError),
68}
69
70impl ProductPreflightPrompt {
71    /// Resolve a host-owned user decision into the final allow/deny outcome.
72    pub fn resolve_with_store<S: SandboxGrantStore>(
73        &self,
74        decision: ProductConsentDecision,
75        grant_store: &mut S,
76    ) -> Result<ProductPreflightOutcome, ProductPreflightError> {
77        match decision {
78            ProductConsentDecision::Approve => {
79                grant_store.save_grant(self.proposed_grant.clone())?;
80                Ok(ProductPreflightOutcome::Allow {
81                    effective_permissions: EffectiveSandboxPermissions::from_grant(
82                        &self.proposed_grant,
83                    ),
84                })
85            }
86            ProductConsentDecision::Deny => match self.deny_behavior {
87                ConsentDenyBehavior::LoadInStrictSandbox => Ok(ProductPreflightOutcome::Allow {
88                    effective_permissions: EffectiveSandboxPermissions::default(),
89                }),
90                ConsentDenyBehavior::DenyLoad => Ok(ProductPreflightOutcome::Deny {
91                    reason: ProductPreflightDenyReason::RequiredSandboxConsentDenied,
92                }),
93            },
94        }
95    }
96}
97
98impl ProductManifest {
99    /// Evaluate a verified manifest into a host-facing allow/prompt/deny decision.
100    pub fn preflight_load<S: SandboxGrantStore>(
101        &self,
102        context: &ProductPreflightContext,
103        grant_store: &S,
104    ) -> Result<ProductPreflightOutcome, ProductPreflightError> {
105        self.validate()?;
106
107        if let Some(supported_chain_genesis_hashes) = &context.supported_chain_genesis_hashes {
108            if let Err(ManifestError::UnsupportedRequiredChains { genesis_hashes }) =
109                self.validate_required_chains_supported(supported_chain_genesis_hashes)
110            {
111                return Ok(ProductPreflightOutcome::Deny {
112                    reason: ProductPreflightDenyReason::UnsupportedRequiredChains {
113                        genesis_hashes,
114                    },
115                });
116            }
117        }
118
119        let stored_grant = grant_store.load_grant(&self.product.id, &context.bundle_hash_or_cid)?;
120
121        match self.evaluate_sandbox_permissions(
122            context.bundle_hash_or_cid.clone(),
123            context.source,
124            context
125                .supported_chain_genesis_hashes
126                .as_deref()
127                .unwrap_or_default(),
128            stored_grant.as_ref(),
129        )? {
130            SandboxPermissionResolution::NoExceptionsRequested => {
131                Ok(ProductPreflightOutcome::Allow {
132                    effective_permissions: EffectiveSandboxPermissions::default(),
133                })
134            }
135            SandboxPermissionResolution::Approved(effective_permissions) => {
136                Ok(ProductPreflightOutcome::Allow {
137                    effective_permissions,
138                })
139            }
140            SandboxPermissionResolution::RequiresUserConsent {
141                summary,
142                proposed_grant,
143            } => {
144                let deny_behavior = if summary
145                    .verified
146                    .sandbox_requests
147                    .iter()
148                    .any(|request| request.required)
149                {
150                    ConsentDenyBehavior::DenyLoad
151                } else {
152                    ConsentDenyBehavior::LoadInStrictSandbox
153                };
154
155                Ok(ProductPreflightOutcome::Prompt(Box::new(
156                    ProductPreflightPrompt {
157                        summary: *summary,
158                        proposed_grant,
159                        deny_behavior,
160                    },
161                )))
162            }
163        }
164    }
165}
166
167impl PreparedProductBundle {
168    /// Convenience wrapper over [`ProductManifest::preflight_load`].
169    pub fn preflight_load<S: SandboxGrantStore>(
170        &self,
171        context: &ProductPreflightContext,
172        grant_store: &S,
173    ) -> Result<ProductPreflightOutcome, ProductPreflightError> {
174        let Some(manifest) = &self.manifest else {
175            return Ok(ProductPreflightOutcome::Allow {
176                effective_permissions: EffectiveSandboxPermissions::default(),
177            });
178        };
179
180        manifest.preflight_load(context, grant_store)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::{
188        grant_store::InMemorySandboxGrantStore,
189        manifest::{
190            HostChainRequirements, HostManifest, HostSandboxRequest, HostSandboxRequirements,
191            ProductMetadata, ProductModality, PublisherMetadata, SandboxRequestKind,
192        },
193    };
194
195    fn manifest(required_request: bool) -> ProductManifest {
196        ProductManifest {
197            product: ProductMetadata {
198                id: "example.dot".into(),
199                name: "Example".into(),
200                version: "1.0.0".into(),
201                description: None,
202                icon: None,
203                modality: ProductModality::Spa,
204            },
205            publisher: Some(PublisherMetadata {
206                display_name: Some("Example Labs".into()),
207                account: Some("5F...".into()),
208            }),
209            host: HostManifest {
210                manifest_version: 1,
211                chains: HostChainRequirements {
212                    required: false,
213                    targets: Vec::new(),
214                },
215                sandbox: HostSandboxRequirements {
216                    version: 1,
217                    requests: vec![HostSandboxRequest {
218                        kind: SandboxRequestKind::Network,
219                        required: required_request,
220                        origins: vec!["https://api.example.com".into()],
221                        reason: Some("Fetches metadata".into()),
222                    }],
223                },
224                analytics: None,
225            },
226        }
227    }
228
229    fn context() -> ProductPreflightContext {
230        ProductPreflightContext {
231            bundle_hash_or_cid: "bafytest".into(),
232            source: ProductSource::Dotns,
233            supported_chain_genesis_hashes: Some(Vec::new()),
234        }
235    }
236
237    #[test]
238    fn preflight_prompts_when_matching_grant_is_missing() {
239        let manifest = manifest(false);
240        let store = InMemorySandboxGrantStore::default();
241
242        let outcome = manifest.preflight_load(&context(), &store).unwrap();
243
244        match outcome {
245            ProductPreflightOutcome::Prompt(prompt) => {
246                assert_eq!(prompt.summary.verified.product_id, "example.dot");
247                assert_eq!(
248                    prompt.deny_behavior,
249                    ConsentDenyBehavior::LoadInStrictSandbox
250                );
251            }
252            other => panic!("unexpected outcome: {other:?}"),
253        }
254    }
255
256    #[test]
257    fn approving_prompt_saves_grant_and_allows_permissions() {
258        let manifest = manifest(false);
259        let store = InMemorySandboxGrantStore::default();
260        let ProductPreflightOutcome::Prompt(prompt) =
261            manifest.preflight_load(&context(), &store).unwrap()
262        else {
263            panic!("expected prompt");
264        };
265
266        let mut store = store;
267        let outcome = prompt
268            .resolve_with_store(ProductConsentDecision::Approve, &mut store)
269            .unwrap();
270
271        assert_eq!(
272            outcome,
273            ProductPreflightOutcome::Allow {
274                effective_permissions: EffectiveSandboxPermissions {
275                    approved_network_origins: vec!["https://api.example.com".into()],
276                },
277            }
278        );
279        assert_eq!(store.grants().len(), 1);
280    }
281
282    #[test]
283    fn denying_optional_prompt_falls_back_to_strict_sandbox() {
284        let manifest = manifest(false);
285        let store = InMemorySandboxGrantStore::default();
286        let ProductPreflightOutcome::Prompt(prompt) =
287            manifest.preflight_load(&context(), &store).unwrap()
288        else {
289            panic!("expected prompt");
290        };
291
292        let mut store = store;
293        let outcome = prompt
294            .resolve_with_store(ProductConsentDecision::Deny, &mut store)
295            .unwrap();
296
297        assert_eq!(
298            outcome,
299            ProductPreflightOutcome::Allow {
300                effective_permissions: EffectiveSandboxPermissions::default(),
301            }
302        );
303        assert!(store.grants().is_empty());
304    }
305
306    #[test]
307    fn denying_required_prompt_denies_load() {
308        let manifest = manifest(true);
309        let store = InMemorySandboxGrantStore::default();
310        let ProductPreflightOutcome::Prompt(prompt) =
311            manifest.preflight_load(&context(), &store).unwrap()
312        else {
313            panic!("expected prompt");
314        };
315
316        let mut store = store;
317        let outcome = prompt
318            .resolve_with_store(ProductConsentDecision::Deny, &mut store)
319            .unwrap();
320
321        assert_eq!(
322            outcome,
323            ProductPreflightOutcome::Deny {
324                reason: ProductPreflightDenyReason::RequiredSandboxConsentDenied,
325            }
326        );
327    }
328
329    #[test]
330    fn preflight_denies_unsupported_required_chains() {
331        let mut manifest = manifest(false);
332        manifest
333            .host
334            .chains
335            .targets
336            .push(crate::manifest::HostChainTarget {
337                genesis_hash: "0x91b171bb158e2d3848fa23a9f1c25182d0f1aa1b1c2d3e4f5a6b7c8d9e0f1122"
338                    .into(),
339                label: Some("polkadot".into()),
340                required: true,
341            });
342
343        let outcome = manifest
344            .preflight_load(&context(), &InMemorySandboxGrantStore::default())
345            .unwrap();
346
347        assert_eq!(
348            outcome,
349            ProductPreflightOutcome::Deny {
350                reason: ProductPreflightDenyReason::UnsupportedRequiredChains {
351                    genesis_hashes: vec![
352                        "0x91b171bb158e2d3848fa23a9f1c25182d0f1aa1b1c2d3e4f5a6b7c8d9e0f1122".into()
353                    ],
354                },
355            }
356        );
357    }
358}