1use crate::api::openai_compat::AppState;
2use crate::package::bridge::PackageLoadInfo;
3use crate::package::config::{load_manifest, PackagePermissions};
4use crate::package::permissions::escalated_permissions;
5use crate::package::{
6 discover_runtime_package, installed_packages_dir, merged_package_aliases,
7 resolve_runtime_package, resolve_wasm_startup_mode, PackageInfo,
8};
9use axum::extract::{Path, State};
10use axum::http::StatusCode;
11use axum::Json;
12use base64::Engine;
13use sha2::{Digest, Sha512};
14use std::fs;
15use std::path::PathBuf;
16
17#[derive(Debug, serde::Deserialize)]
18pub struct InstallPackageRequest {
19 name: String,
20 #[serde(default)]
21 version: String,
22 store_base_url: String,
23 #[serde(default)]
26 approve_escalation: bool,
27}
28
29#[derive(Debug, serde::Deserialize)]
30struct StoreEnvelope<T> {
31 success: bool,
32 data: T,
33}
34
35#[derive(Debug, serde::Deserialize)]
36struct StorePackageDetail {
37 latest: Option<StoreVersionDetail>,
38 #[serde(default)]
39 versions: Vec<StoreVersionDetail>,
40}
41
42#[derive(Debug, Clone, serde::Deserialize)]
43struct StoreVersionDetail {
44 version: String,
45 #[serde(default)]
46 manifest: serde_json::Value,
47 #[serde(default)]
48 artifact_digest: String,
49}
50
51fn managed_packages_dir(state: &AppState) -> PathBuf {
52 installed_packages_dir(&state.repo_root)
53}
54
55fn resolve_managed_package_dir(state: &AppState, name: &str) -> PathBuf {
59 resolve_runtime_package(&state.repo_root, &state.package_index, name)
60 .map(|pkg| pkg.dir)
61 .unwrap_or_else(|| managed_packages_dir(state).join(name))
62}
63
64fn parse_manifest_permissions(manifest_bytes: &[u8]) -> PackagePermissions {
68 #[derive(serde::Deserialize, Default)]
69 struct PermsOnly {
70 #[serde(default)]
71 permissions: PackagePermissions,
72 }
73 std::str::from_utf8(manifest_bytes)
74 .ok()
75 .and_then(|s| toml::from_str::<PermsOnly>(s).ok())
76 .map(|p| p.permissions)
77 .unwrap_or_default()
78}
79
80fn json_error(
81 status: StatusCode,
82 message: impl Into<String>,
83) -> (StatusCode, Json<serde_json::Value>) {
84 (status, Json(serde_json::json!({ "error": message.into() })))
85}
86
87fn sha512_hex(bytes: &[u8]) -> String {
88 let mut hasher = Sha512::new();
89 hasher.update(bytes);
90 format!("{:x}", hasher.finalize())
91}
92
93fn decode_manifest_bytes(value: &serde_json::Value) -> Result<Vec<u8>, String> {
94 match value {
95 serde_json::Value::String(text) => base64::engine::general_purpose::STANDARD
96 .decode(text)
97 .or_else(|_| Ok(text.as_bytes().to_vec()))
98 .map_err(|error: base64::DecodeError| format!("Failed to decode manifest: {error}")),
99 serde_json::Value::Array(items) => {
100 let mut bytes = Vec::with_capacity(items.len());
101 for item in items {
102 let Some(value) = item.as_u64() else {
103 return Err("Manifest byte array contains non-numeric values".into());
104 };
105 let Ok(byte) = u8::try_from(value) else {
106 return Err("Manifest byte array contains values outside u8 range".into());
107 };
108 bytes.push(byte);
109 }
110 Ok(bytes)
111 }
112 serde_json::Value::Object(_) => serde_json::to_vec_pretty(value)
113 .map_err(|error| format!("Failed to serialize manifest object: {error}")),
114 serde_json::Value::Null => Err("Manifest payload is missing".into()),
115 _ => Err("Manifest payload has unsupported format".into()),
116 }
117}
118
119fn select_store_version<'a>(
120 detail: &'a StorePackageDetail,
121 requested_version: &str,
122) -> Option<&'a StoreVersionDetail> {
123 if requested_version.is_empty() {
124 return detail.latest.as_ref().or_else(|| detail.versions.first());
125 }
126
127 detail
128 .latest
129 .as_ref()
130 .filter(|latest| latest.version == requested_version)
131 .or_else(|| {
132 detail
133 .versions
134 .iter()
135 .find(|version| version.version == requested_version)
136 })
137}
138
139fn trim_store_base_url(base_url: &str) -> String {
140 base_url.trim().trim_end_matches('/').to_string()
141}
142
143async fn runtime_package_aliases_for_host(
144 state: &AppState,
145) -> std::collections::HashMap<String, String> {
146 let package_aliases = {
147 let config = state.config.read().await;
148 config.package_aliases.clone()
149 };
150 merged_package_aliases(&state.package_index, &package_aliases)
151}
152
153fn runtime_package_or_404(
154 state: &AppState,
155 name: &str,
156) -> Result<crate::package::DiscoveredPackage, (StatusCode, Json<serde_json::Value>)> {
157 resolve_runtime_package(&state.repo_root, &state.package_index, name).ok_or_else(|| {
158 (
159 StatusCode::NOT_FOUND,
160 Json(serde_json::json!({
161 "error": format!("Package '{}' not found in runtime sources", name)
162 })),
163 )
164 })
165}
166
167pub async fn list_packages(State(state): State<AppState>) -> Json<serde_json::Value> {
168 let pm = state.package_manager.read().await;
169 let packages: Vec<serde_json::Value> = pm
170 .list()
171 .iter()
172 .map(|p| {
173 serde_json::json!({
174 "name": p.name,
175 "version": p.version,
176 "overrides": p.overrides,
177 "enabled": p.enabled,
178 "has_ui": p.has_ui,
179 "description": p.description,
180 })
181 })
182 .collect();
183 Json(serde_json::json!({ "packages": packages }))
184}
185
186pub async fn install_package(
188 State(state): State<AppState>,
189 Json(body): Json<InstallPackageRequest>,
190) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
191 let name = body.name.trim();
192 let requested_version = body.version.trim();
193 let store_base_url = trim_store_base_url(&body.store_base_url);
194
195 if name.is_empty() {
196 return Err(json_error(StatusCode::BAD_REQUEST, "Package name is required"));
197 }
198 if store_base_url.is_empty() {
199 return Err(json_error(
200 StatusCode::BAD_REQUEST,
201 "store_base_url is required",
202 ));
203 }
204
205 let detail_url = format!("{store_base_url}/api/packages/{name}");
206 let download_url = format!("{store_base_url}/api/packages/{name}/download");
207 let client = reqwest::Client::new();
208
209 let detail_response = client
210 .get(&detail_url)
211 .query(&[("version", requested_version)])
212 .send()
213 .await
214 .map_err(|error| {
215 json_error(
216 StatusCode::BAD_GATEWAY,
217 format!("Failed to fetch package detail: {error}"),
218 )
219 })?
220 .error_for_status()
221 .map_err(|error| {
222 json_error(
223 StatusCode::BAD_GATEWAY,
224 format!("Store detail request failed: {error}"),
225 )
226 })?;
227
228 let envelope: StoreEnvelope<StorePackageDetail> = detail_response.json().await.map_err(|error| {
229 json_error(
230 StatusCode::BAD_GATEWAY,
231 format!("Failed to decode store detail response: {error}"),
232 )
233 })?;
234 if !envelope.success {
235 return Err(json_error(
236 StatusCode::BAD_GATEWAY,
237 "Store detail response indicated failure",
238 ));
239 }
240
241 let selected = select_store_version(&envelope.data, requested_version).ok_or_else(|| {
242 json_error(
243 StatusCode::NOT_FOUND,
244 if requested_version.is_empty() {
245 format!("No published version found for package '{name}'")
246 } else {
247 format!("Package '{name}' version '{requested_version}' not found")
248 },
249 )
250 })?;
251
252 let expected_digest = selected.artifact_digest.trim();
253 if expected_digest.is_empty() {
254 return Err(json_error(
255 StatusCode::BAD_GATEWAY,
256 format!("Store detail for package '{name}' is missing artifact_digest"),
257 ));
258 }
259
260 let manifest_bytes = decode_manifest_bytes(&selected.manifest)
261 .map_err(|error| json_error(StatusCode::BAD_GATEWAY, error))?;
262
263 let download_response = client
264 .get(&download_url)
265 .query(&[("version", selected.version.as_str())])
266 .send()
267 .await
268 .map_err(|error| {
269 json_error(
270 StatusCode::BAD_GATEWAY,
271 format!("Failed to download package artifact: {error}"),
272 )
273 })?
274 .error_for_status()
275 .map_err(|error| {
276 json_error(
277 StatusCode::BAD_GATEWAY,
278 format!("Store artifact request failed: {error}"),
279 )
280 })?;
281
282 let header_digest = download_response
283 .headers()
284 .get("X-Artifact-Digest")
285 .and_then(|value| value.to_str().ok())
286 .map(str::trim)
287 .filter(|value| !value.is_empty())
288 .map(str::to_owned);
289 let expected_digest = header_digest.as_deref().unwrap_or(expected_digest);
290
291 let artifact_bytes = download_response.bytes().await.map_err(|error| {
292 json_error(
293 StatusCode::BAD_GATEWAY,
294 format!("Failed to read downloaded package artifact: {error}"),
295 )
296 })?;
297
298 let actual_digest = sha512_hex(artifact_bytes.as_ref());
299 if !actual_digest.eq_ignore_ascii_case(expected_digest) {
300 return Err(json_error(
301 StatusCode::BAD_REQUEST,
302 format!(
303 "Artifact digest mismatch for package '{name}': expected {expected_digest}, got {actual_digest}"
304 ),
305 ));
306 }
307
308 let package_dir = installed_packages_dir(&state.repo_root).join(name);
309
310 let old_permissions = if package_dir.join("package.toml").exists() {
314 load_manifest(&package_dir)
315 .map(|m| m.permissions)
316 .unwrap_or_default()
317 } else {
318 PackagePermissions::default()
319 };
320 let new_permissions = parse_manifest_permissions(&manifest_bytes);
321 let escalated = escalated_permissions(&old_permissions, &new_permissions);
322 if !escalated.is_empty() && !body.approve_escalation {
323 let names: Vec<String> = escalated.iter().map(|p| p.to_string()).collect();
324 return Err((
325 StatusCode::CONFLICT,
326 Json(serde_json::json!({
327 "error": format!(
328 "Upgrade of '{name}' requests new permissions not held by the \
329 installed version: [{}]. Re-install with approve_escalation=true \
330 to grant them.",
331 names.join(", ")
332 ),
333 "escalation": names,
334 "requires_approval": true,
335 })),
336 ));
337 }
338
339 fs::create_dir_all(&package_dir).map_err(|error| {
340 json_error(
341 StatusCode::INTERNAL_SERVER_ERROR,
342 format!("Failed to create package directory: {error}"),
343 )
344 })?;
345 fs::write(package_dir.join("package.wasm"), artifact_bytes.as_ref()).map_err(|error| {
346 json_error(
347 StatusCode::INTERNAL_SERVER_ERROR,
348 format!("Failed to write package.wasm: {error}"),
349 )
350 })?;
351 fs::write(package_dir.join("package.toml"), &manifest_bytes).map_err(|error| {
352 json_error(
353 StatusCode::INTERNAL_SERVER_ERROR,
354 format!("Failed to write package.toml: {error}"),
355 )
356 })?;
357
358 let discovered = discover_runtime_package(&state.repo_root, &state.package_index, name)
360 .ok_or_else(|| {
361 json_error(
362 StatusCode::INTERNAL_SERVER_ERROR,
363 format!(
364 "Installed package '{name}' could not be rediscovered after writing files"
365 ),
366 )
367 })?;
368
369 let mut pm = state.package_manager.write().await;
370 pm.register(PackageInfo {
371 name: discovered.manifest.package_info.name.clone(),
372 version: Some(discovered.manifest.package_info.version.clone()),
373 overrides: vec![],
374 enabled: true,
375 has_ui: false,
376 description: Some(discovered.manifest.package_info.description.clone()),
377 });
378
379 Ok(Json(serde_json::json!({
380 "installed": discovered.manifest.package_info.name,
381 "version": discovered.manifest.package_info.version,
382 "digest_verified": true,
383 })))
384}
385
386pub async fn reload_package(
388 State(state): State<AppState>,
389 Path(name): Path<String>,
390) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
391 let package = runtime_package_or_404(&state, &name)?;
392 let package_dir = package.dir.clone();
393 let manifest = package.manifest.clone();
394 let runtime_package_name = manifest.package_info.name.clone();
395 let wasm_path = package.entry_path.clone().ok_or_else(|| {
396 (
397 StatusCode::BAD_REQUEST,
398 Json(serde_json::json!({
399 "error": format!("Package '{}' has no runtime entry to reload", name)
400 })),
401 )
402 })?;
403
404 let load_info = PackageLoadInfo {
405 name: runtime_package_name.clone(),
406 dir: package_dir.clone(),
407 wasm_path,
408 startup_mode: resolve_wasm_startup_mode(&manifest),
409 permissions: manifest.permissions.clone(),
410 };
411
412 let mut wh = state.wasm_handle.write().await;
414 if let Some(ref handle) = *wh {
415 handle.reload_package(&load_info).map_err(|e| {
416 (
417 StatusCode::INTERNAL_SERVER_ERROR,
418 Json(serde_json::json!({"error": format!("Reload failed: {}", e)})),
419 )
420 })?;
421 } else {
422 let package_aliases = runtime_package_aliases_for_host(&state).await;
423 let host_state = crate::package::bridge::WasmHostState {
425 config: state.config.clone(),
426 pipeline: state.pipeline.clone(),
427 runtime_handle: crate::package::bridge::host_blocking_handle(),
430 process_manager: state.process_manager.clone(),
431 vkey_store: state.vkey_store.clone(),
432 kv_store: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
433 caller_package_name: String::new(),
434 package_dir: String::new(),
435 permissions: Default::default(),
436 package_map: std::sync::Arc::new(std::sync::Mutex::new(
437 std::collections::HashMap::new(),
438 )),
439 package_aliases: std::sync::Arc::new(std::sync::Mutex::new(package_aliases)),
440 call_depth: std::sync::Arc::new(std::sync::Mutex::new(0)),
441 app_state: std::sync::Arc::new(std::sync::Mutex::new(Some(state.clone()))),
442 };
443 let host = crate::package::bridge::WasmPackageHost::new(&[load_info], host_state);
444 *wh = Some(crate::package::bridge::WasmHandle::new(host));
445 }
446
447 let mut pm = state.package_manager.write().await;
449 pm.register(PackageInfo {
450 name: runtime_package_name,
451 version: Some(manifest.package_info.version.clone()),
452 overrides: vec![],
453 enabled: true,
454 has_ui: false,
455 description: Some(manifest.package_info.description.clone()),
456 });
457
458 Ok(Json(
459 serde_json::json!({"status": "ok", "message": format!("Package '{}' reloaded", name)}),
460 ))
461}
462
463pub async fn toggle_package(
465 State(state): State<AppState>,
466 Path(name): Path<String>,
467 Json(body): Json<serde_json::Value>,
468) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
469 let enabled = body["enabled"].as_bool().unwrap_or(true);
470
471 let runtime_package_name =
472 resolve_runtime_package(&state.repo_root, &state.package_index, &name)
473 .map(|package| package.manifest.package_info.name)
474 .unwrap_or_else(|| name.clone());
475
476 let mut pm = state.package_manager.write().await;
477 let info = pm.get_mut(&runtime_package_name).ok_or_else(|| {
478 (
479 StatusCode::NOT_FOUND,
480 Json(serde_json::json!({"error": format!("Package '{}' not found", name)})),
481 )
482 })?;
483 info.enabled = enabled;
484
485 let wh = state.wasm_handle.read().await;
487 if let Some(ref handle) = *wh {
488 if !enabled {
489 let _ = handle.unload_package(&runtime_package_name);
490 } else {
491 drop(wh);
493 if let Ok(package) = runtime_package_or_404(&state, &name) {
494 if let Some(wasm_path) = package.entry_path.clone() {
495 let load_info = PackageLoadInfo {
496 name: package.manifest.package_info.name.clone(),
497 dir: package.dir.clone(),
498 wasm_path,
499 startup_mode: resolve_wasm_startup_mode(&package.manifest),
500 permissions: package.manifest.permissions.clone(),
501 };
502 let wh = state.wasm_handle.read().await;
503 if let Some(ref handle) = *wh {
504 let _ = handle.load_package(&load_info);
505 }
506 }
507 }
508 }
509 }
510
511 Ok(Json(
512 serde_json::json!({"status": "ok", "name": name, "runtime_package": runtime_package_name, "enabled": enabled}),
513 ))
514}
515
516pub async fn uninstall_package(
518 State(state): State<AppState>,
519 Path(name): Path<String>,
520) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
521 let packages_dir = managed_packages_dir(&state);
522
523 let dependents = crate::package::check_uninstall_safety(&name, &packages_dir);
525 if !dependents.is_empty() {
526 return Err((
527 StatusCode::CONFLICT,
528 Json(serde_json::json!({
529 "error": format!("Cannot uninstall '{}': depended on by {:?}", name, dependents),
530 "dependents": dependents,
531 })),
532 ));
533 }
534
535 {
537 let wh = state.wasm_handle.read().await;
538 if let Some(ref handle) = *wh {
539 let _ = handle.unload_package(&name);
540 }
541 }
542
543 {
545 let mut pm = state.package_manager.write().await;
546 pm.unregister(&name);
547 }
548
549 let package_dir = packages_dir.join(&name);
551 if package_dir.exists() {
552 tokio::fs::remove_dir_all(&package_dir).await.map_err(|e| {
553 (
554 StatusCode::INTERNAL_SERVER_ERROR,
555 Json(
556 serde_json::json!({"error": format!("Failed to delete package files: {}", e)}),
557 ),
558 )
559 })?;
560 }
561
562 Ok(Json(
563 serde_json::json!({"status": "ok", "message": format!("Package '{}' uninstalled", name)}),
564 ))
565}
566
567pub async fn get_package_dependencies(
569 State(state): State<AppState>,
570 Path(name): Path<String>,
571) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
572 let packages_dir = managed_packages_dir(&state);
573 let package_dir = packages_dir.join(&name);
574 let manifest = crate::package::config::load_manifest(&package_dir).map_err(|e| {
575 (
576 StatusCode::NOT_FOUND,
577 Json(serde_json::json!({"error": format!("Package not found: {}", e)})),
578 )
579 })?;
580
581 let pm = state.package_manager.read().await;
582 let installed: Vec<_> = pm.list().into_iter().cloned().collect();
583 let missing = crate::package::check_dependencies(&manifest, &installed);
584 let dependents = crate::package::check_uninstall_safety(&name, &packages_dir);
585
586 Ok(Json(serde_json::json!({
587 "name": name,
588 "dependencies": manifest.dependencies,
589 "missing": missing,
590 "depended_by": dependents,
591 })))
592}
593
594pub async fn get_package_config_schema(
596 State(state): State<AppState>,
597 Path(name): Path<String>,
598) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
599 let package_dir = resolve_managed_package_dir(&state, &name);
600 let manifest = crate::package::config::load_manifest(&package_dir).map_err(|e| {
601 (
602 StatusCode::NOT_FOUND,
603 Json(serde_json::json!({"error": format!("Package not found: {}", e)})),
604 )
605 })?;
606
607 Ok(Json(serde_json::json!({
608 "name": name,
609 "schema": manifest.config,
610 })))
611}
612
613pub async fn get_package_config(
615 State(state): State<AppState>,
616 Path(name): Path<String>,
617) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
618 let package_dir = resolve_managed_package_dir(&state, &name);
619 let config_path = package_dir.join("config.json");
620
621 let config: serde_json::Value = if config_path.exists() {
622 let content = tokio::fs::read_to_string(&config_path).await.map_err(|e| {
623 (
624 StatusCode::INTERNAL_SERVER_ERROR,
625 Json(serde_json::json!({"error": format!("Failed to read config: {}", e)})),
626 )
627 })?;
628 serde_json::from_str(&content).unwrap_or_default()
629 } else {
630 serde_json::json!({})
631 };
632
633 let manifest = crate::package::config::load_manifest(&package_dir).ok();
635
636 let masked = if let Some(manifest) = manifest {
637 mask_secret_fields(&config, &manifest.config)
638 } else {
639 config
640 };
641
642 Ok(Json(serde_json::json!({
643 "name": name,
644 "config": masked,
645 })))
646}
647
648pub async fn save_package_config(
650 State(state): State<AppState>,
651 Path(name): Path<String>,
652 Json(body): Json<serde_json::Value>,
653) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
654 let package_dir = resolve_managed_package_dir(&state, &name);
655 let config_path = package_dir.join("config.json");
656
657 if !package_dir.exists() {
659 return Err((
660 StatusCode::NOT_FOUND,
661 Json(serde_json::json!({"error": format!("Package '{}' not found", name)})),
662 ));
663 }
664
665 let content = serde_json::to_string_pretty(&body).map_err(|e| {
667 (
668 StatusCode::BAD_REQUEST,
669 Json(serde_json::json!({"error": format!("Invalid JSON: {}", e)})),
670 )
671 })?;
672 tokio::fs::write(&config_path, &content)
673 .await
674 .map_err(|e| {
675 (
676 StatusCode::INTERNAL_SERVER_ERROR,
677 Json(serde_json::json!({"error": format!("Failed to write config: {}", e)})),
678 )
679 })?;
680
681 {
683 let wh = state.wasm_handle.read().await;
684 if let Some(ref handle) = *wh {
685 if handle.has_package(&name) {
686 let _ = handle.call(&name, "on_config_change", &content);
687 }
688 }
689 }
690
691 Ok(Json(
692 serde_json::json!({"status": "ok", "message": format!("Config saved for '{}'", name)}),
693 ))
694}
695
696fn mask_secret_fields(
698 config: &serde_json::Value,
699 schema: &std::collections::HashMap<String, crate::package::config::PackageConfigField>,
700) -> serde_json::Value {
701 let Some(obj) = config.as_object() else {
702 return config.clone();
703 };
704
705 let mut masked = obj.clone();
706 for (key, field) in schema {
707 if field.secret {
708 if let Some(serde_json::Value::String(val)) = masked.get(key) {
709 if val.len() > 4 {
710 let suffix = &val[val.len() - 4..];
711 masked.insert(key.clone(), serde_json::json!(format!("****{}", suffix)));
712 } else {
713 masked.insert(key.clone(), serde_json::json!("****"));
714 }
715 }
716 }
717 }
718 serde_json::Value::Object(masked)
719}
720
721#[cfg(test)]
722mod b4_tests {
723 use super::parse_manifest_permissions;
724 use crate::package::permissions::{escalated_permissions, Permission};
725
726 #[test]
727 fn parses_permissions_from_package_info_dialect() {
728 let toml = br#"
729[package_info]
730name = "x"
731version = "1.0"
732description = "d"
733entry = "package.wasm"
734
735[permissions]
736storage = true
737network = true
738"#;
739 let p = parse_manifest_permissions(toml);
740 assert!(p.storage && p.network && !p.process);
741 }
742
743 #[test]
744 fn parses_permissions_from_identity_dialect() {
745 let toml = br#"
746[identity]
747name = "x"
748version = "1.0"
749
750[package]
751runtime = "wasm"
752
753[permissions]
754process = true
755"#;
756 let p = parse_manifest_permissions(toml);
757 assert!(p.process && !p.storage);
758 }
759
760 #[test]
761 fn missing_permissions_section_yields_no_grants() {
762 let toml = br#"
763[package_info]
764name = "x"
765version = "1.0"
766description = "d"
767"#;
768 let p = parse_manifest_permissions(toml);
769 assert!(escalated_permissions(&Default::default(), &p).is_empty());
770 }
771
772 #[test]
773 fn upgrade_adding_process_is_flagged_as_escalation() {
774 let old = parse_manifest_permissions(
775 b"[package_info]\nname='x'\nversion='1'\ndescription='d'\n[permissions]\nstorage=true\n",
776 );
777 let new = parse_manifest_permissions(
778 b"[package_info]\nname='x'\nversion='2'\ndescription='d'\n[permissions]\nstorage=true\nprocess=true\n",
779 );
780 let esc = escalated_permissions(&old, &new);
781 assert_eq!(esc, vec![Permission::Process]);
782 }
783}