Skip to main content

weft_core/api/
packages_runtime.rs

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    /// B4: must be explicitly set true to install an upgrade that requests more
24    /// permissions than the currently-installed version.
25    #[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
55/// Resolve a package's on-disk directory by runtime name, honoring index.toml
56/// `current_source` (so packages whose source lives under `official/` are found,
57/// not just `installed/`). Falls back to `installed/<name>` for compatibility.
58fn 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
64/// Parses just the `[permissions]` section from raw manifest bytes. Tolerant of
65/// the multiple package.toml dialects (other sections are ignored), so it works
66/// whether the manifest uses `[package_info]` or `[identity]+[package]`.
67fn 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
186/// POST /api/packages/install — install a package from a remote store.
187pub 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    // B4: detect permission escalation before overwriting the installed package.
311    // Old permissions come from the currently-installed manifest (if any); new
312    // permissions are parsed from the manifest about to be written.
313    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    // TODO: enforce signature verification once platform key infrastructure is available.
359    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
386/// POST /api/packages/{name}/reload — reload a package's WASM without restarting
387pub 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    // Reload WASM
413    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        // No WasmHandle yet — create one
424        let host_state = crate::package::bridge::WasmHostState {
425            config: state.config.clone(),
426            pipeline: state.pipeline.clone(),
427            // Dedicated host runtime (see bridge::host_blocking_handle) — must
428            // match main.rs so nested host block_on never runs on the HTTP runtime.
429            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    // Update PackageManager metadata
448    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
463/// POST /api/packages/{name}/toggle — enable/disable a package
464pub 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    // If disabling, unload WASM; if enabling, reload WASM
486    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            // Need to drop read lock and acquire write lock for reload
492            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
516/// DELETE /api/packages/{name} — uninstall a package (unload WASM + delete files)
517pub 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    // Check if other packages depend on this one
524    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    // Unload WASM first
536    {
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    // Remove from PackageManager
544    {
545        let mut pm = state.package_manager.write().await;
546        pm.unregister(&name);
547    }
548
549    // Delete package directory
550    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
567/// GET /api/packages/{name}/dependencies — view package dependency info
568pub 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
594/// GET /api/packages/{name}/config/schema — get package config schema
595pub 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
613/// GET /api/packages/{name}/config — get current config values
614pub 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    // Load schema to mask secret fields
634    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
648/// PUT /api/packages/{name}/config — save config values
649pub 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    // Validate package exists
658    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    // Write config
666    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    // Notify package of config change (best-effort)
682    {
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
696/// Mask secret fields in config values for GET responses.
697fn 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}