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#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ProductPreflightContext {
14 pub bundle_hash_or_cid: String,
15 pub source: ProductSource,
16 pub supported_chain_genesis_hashes: Option<Vec<String>>,
19}
20
21#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43pub enum ConsentDenyBehavior {
44 LoadInStrictSandbox,
45 DenyLoad,
46}
47
48#[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 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 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 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}