Skip to main content

greentic_runner_host/
pack.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::fs::File;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::Arc;
7use std::time::Duration;
8
9use crate::cache::{ArtifactKey, CacheConfig, CacheManager, CpuPolicy, EngineProfile};
10use crate::component_api::{
11    self, node::ExecCtx as ComponentExecCtx, node::InvokeResult, node::NodeError,
12};
13use crate::oauth::{OAuthBrokerConfig, OAuthBrokerHost, OAuthHostContext};
14use crate::provider::{ProviderBinding, ProviderRegistry};
15use crate::provider_core::{
16    schema_core::SchemaCorePre as LegacySchemaCorePre,
17    schema_core_path::SchemaCorePre as PathSchemaCorePre,
18    schema_core_schema::SchemaCorePre as SchemaSchemaCorePre,
19};
20use crate::provider_core_only;
21use crate::runtime_wasmtime::{Component, Engine, InstancePre, Linker, ResourceTable};
22use anyhow::{Context, Result, anyhow, bail};
23use futures::executor::block_on;
24use greentic_distributor_client::dist::{
25    CachePolicy, DistClient, DistError, DistOptions, ResolvePolicy,
26};
27use greentic_interfaces_wasmtime::host_helpers::v1::{
28    self as host_v1, HostFns, add_all_v1_to_linker,
29    runner_host_http::RunnerHostHttp,
30    runner_host_kv::RunnerHostKv,
31    secrets_store::{SecretsError, SecretsErrorV1_1, SecretsStoreHost, SecretsStoreHostV1_1},
32    state_store::{
33        OpAck as StateOpAck, StateKey as HostStateKey, StateStoreError as StateError,
34        StateStoreHost, TenantCtx as StateTenantCtx,
35    },
36    telemetry_logger::{
37        OpAck as TelemetryAck, SpanContext as TelemetrySpanContext,
38        TelemetryLoggerError as TelemetryError, TelemetryLoggerHost,
39        TenantCtx as TelemetryTenantCtx,
40    },
41};
42use greentic_interfaces_wasmtime::http_client_client_v1_1::greentic::http::http_client as http_client_client_alias;
43use greentic_interfaces_wasmtime::{
44    http_client_client_v1_0::greentic::interfaces_types::types as http_types_v1_0,
45    http_client_client_v1_1::greentic::interfaces_types::types as http_types_v1_1,
46};
47use greentic_pack::builder as legacy_pack;
48use greentic_types::flow::FlowHasher;
49use greentic_types::{
50    ArtifactLocationV1, ComponentId, ComponentManifest, ComponentSourceRef, ComponentSourcesV1,
51    EXT_COMPONENT_SOURCES_V1, EnvId, ExtensionRef, Flow, FlowComponentRef, FlowId, FlowKind,
52    FlowMetadata, InputMapping, Node, NodeId, OutputMapping, Routing, StateKey as StoreStateKey,
53    TeamId, TelemetryHints, TenantCtx as TypesTenantCtx, TenantId, UserId, decode_pack_manifest,
54    pack_manifest::ExtensionInline,
55};
56use host_v1::http_client as host_http_client;
57use host_v1::http_client::{
58    HttpClientError, HttpClientErrorV1_1, HttpClientHost, HttpClientHostV1_1,
59    Request as HttpRequest, RequestOptionsV1_1 as HttpRequestOptionsV1_1,
60    RequestV1_1 as HttpRequestV1_1, Response as HttpResponse, ResponseV1_1 as HttpResponseV1_1,
61    TenantCtx as HttpTenantCtx, TenantCtxV1_1 as HttpTenantCtxV1_1,
62};
63use indexmap::IndexMap;
64use once_cell::sync::Lazy;
65use parking_lot::{Mutex, RwLock};
66use reqwest::blocking::Client as BlockingClient;
67use runner_core::normalize_under_root;
68use serde::{Deserialize, Serialize};
69use serde_cbor;
70use serde_json::{self, Value};
71use sha2::Digest;
72use tempfile::TempDir;
73use tokio::fs;
74use wasmparser::{Parser, Payload};
75use wasmtime::{Store, StoreContextMut};
76use wasmtime_wasi_http::WasiHttpCtx;
77use wasmtime_wasi_http::p2::{
78    WasiHttpCtxView, WasiHttpView, add_only_http_to_linker_sync as add_wasi_http_to_linker,
79};
80use wasmtime_wasi_tls::{LinkOptions, WasiTls, WasiTlsCtx, WasiTlsCtxBuilder};
81use zip::ZipArchive;
82
83use crate::runner::engine::{FlowContext, FlowEngine, FlowStatus};
84use crate::runner::flow_adapter::{FlowIR, flow_doc_to_ir, flow_ir_to_flow};
85use crate::runner::mocks::{HttpDecision, HttpMockRequest, HttpMockResponse, MockLayer};
86#[cfg(feature = "fault-injection")]
87use crate::testing::fault_injection::{FaultContext, FaultPoint, maybe_fail};
88
89use crate::config::HostConfig;
90use crate::fault;
91use crate::secrets::{
92    DynSecretsManager, canonicalize_secret_key, read_secret_blocking, write_secret_blocking,
93};
94use crate::storage::state::STATE_PREFIX;
95use crate::storage::{DynSessionStore, DynStateStore};
96use crate::verify;
97use crate::wasi::{PreopenSpec, RunnerWasiPolicy};
98use tracing::warn;
99use wasmtime_wasi::p2::add_to_linker_sync as add_wasi_to_linker;
100use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
101
102use greentic_flow::model::FlowDoc;
103
104#[allow(dead_code)]
105pub struct PackRuntime {
106    /// Component artifact path (wasm file).
107    path: PathBuf,
108    /// Optional archive (.gtpack) used to load flows/manifests.
109    archive_path: Option<PathBuf>,
110    config: Arc<HostConfig>,
111    engine: Engine,
112    metadata: PackMetadata,
113    manifest: Option<greentic_types::PackManifest>,
114    legacy_manifest: Option<Box<legacy_pack::PackManifest>>,
115    component_manifests: HashMap<String, ComponentManifest>,
116    mocks: Option<Arc<MockLayer>>,
117    flows: Option<PackFlows>,
118    components: HashMap<String, PackComponent>,
119    http_client: Arc<BlockingClient>,
120    pre_cache: Mutex<HashMap<String, InstancePre<ComponentState>>>,
121    session_store: Option<DynSessionStore>,
122    state_store: Option<DynStateStore>,
123    wasi_policy: Arc<RunnerWasiPolicy>,
124    assets_tempdir: Option<TempDir>,
125    provider_registry: RwLock<Option<ProviderRegistry>>,
126    secrets: DynSecretsManager,
127    oauth_config: Option<OAuthBrokerConfig>,
128    cache: CacheManager,
129}
130
131struct PackComponent {
132    #[allow(dead_code)]
133    name: String,
134    #[allow(dead_code)]
135    version: String,
136    component: Arc<Component>,
137}
138
139fn run_on_wasi_thread<F, T>(task_name: &'static str, task: F) -> Result<T>
140where
141    F: FnOnce() -> Result<T> + Send + 'static,
142    T: Send + 'static,
143{
144    let builder = std::thread::Builder::new().name(format!("greentic-wasmtime-{task_name}"));
145    let handle = builder
146        .spawn(move || {
147            let pid = std::process::id();
148            let thread_id = std::thread::current().id();
149            let tokio_handle_present = tokio::runtime::Handle::try_current().is_ok();
150            tracing::info!(
151                event = "wasmtime.thread.start",
152                task = task_name,
153                pid,
154                thread_id = ?thread_id,
155                tokio_handle_present,
156                "starting Wasmtime thread"
157            );
158            task()
159        })
160        .context("failed to spawn Wasmtime thread")?;
161    handle
162        .join()
163        .map_err(|err| {
164            let reason = if let Some(msg) = err.downcast_ref::<&str>() {
165                msg.to_string()
166            } else if let Some(msg) = err.downcast_ref::<String>() {
167                msg.clone()
168            } else {
169                "unknown panic".to_string()
170            };
171            anyhow!("Wasmtime thread panicked: {reason}")
172        })
173        .and_then(|res| res)
174}
175
176#[derive(Debug, Default, Clone)]
177pub struct ComponentResolution {
178    /// Root of a materialized pack directory containing `manifest.cbor` and `components/`.
179    pub materialized_root: Option<PathBuf>,
180    /// Explicit overrides mapping component id -> wasm path.
181    pub overrides: HashMap<String, PathBuf>,
182    /// If true, do not fetch remote components; require cached artifacts.
183    pub dist_offline: bool,
184    /// Optional cache directory for resolved remote components.
185    pub dist_cache_dir: Option<PathBuf>,
186    /// Allow bundled components without wasm_sha256 (dev-only escape hatch).
187    pub allow_missing_hash: bool,
188}
189
190fn build_blocking_client() -> BlockingClient {
191    std::thread::spawn(|| {
192        BlockingClient::builder()
193            .no_proxy()
194            .build()
195            .expect("blocking client")
196    })
197    .join()
198    .expect("client build thread panicked")
199}
200
201fn normalize_pack_path(path: &Path) -> Result<(PathBuf, PathBuf)> {
202    let (root, candidate) = if path.is_absolute() {
203        let parent = path
204            .parent()
205            .ok_or_else(|| anyhow!("pack path {} has no parent", path.display()))?;
206        let root = parent
207            .canonicalize()
208            .with_context(|| format!("failed to canonicalize {}", parent.display()))?;
209        let file = path
210            .file_name()
211            .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
212        (root, PathBuf::from(file))
213    } else {
214        let cwd = std::env::current_dir().context("failed to resolve current directory")?;
215        let base = if let Some(parent) = path.parent() {
216            cwd.join(parent)
217        } else {
218            cwd
219        };
220        let root = base
221            .canonicalize()
222            .with_context(|| format!("failed to canonicalize {}", base.display()))?;
223        let file = path
224            .file_name()
225            .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
226        (root, PathBuf::from(file))
227    };
228    let safe = normalize_under_root(&root, &candidate)?;
229    Ok((root, safe))
230}
231
232static HTTP_CLIENT: Lazy<Arc<BlockingClient>> = Lazy::new(|| Arc::new(build_blocking_client()));
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct FlowDescriptor {
236    pub id: String,
237    #[serde(rename = "type")]
238    pub flow_type: String,
239    pub pack_id: String,
240    pub profile: String,
241    pub version: String,
242    #[serde(default)]
243    pub description: Option<String>,
244}
245
246pub struct HostState {
247    #[allow(dead_code)]
248    pack_id: String,
249    config: Arc<HostConfig>,
250    http_client: Arc<BlockingClient>,
251    default_env: String,
252    #[allow(dead_code)]
253    session_store: Option<DynSessionStore>,
254    state_store: Option<DynStateStore>,
255    mocks: Option<Arc<MockLayer>>,
256    secrets: DynSecretsManager,
257    oauth_config: Option<OAuthBrokerConfig>,
258    oauth_host: OAuthBrokerHost,
259    exec_ctx: Option<ComponentExecCtx>,
260    component_ref: Option<String>,
261    provider_core_component: bool,
262}
263
264impl HostState {
265    #[allow(clippy::default_constructed_unit_structs)]
266    #[allow(clippy::too_many_arguments)]
267    pub fn new(
268        pack_id: String,
269        config: Arc<HostConfig>,
270        http_client: Arc<BlockingClient>,
271        mocks: Option<Arc<MockLayer>>,
272        session_store: Option<DynSessionStore>,
273        state_store: Option<DynStateStore>,
274        secrets: DynSecretsManager,
275        oauth_config: Option<OAuthBrokerConfig>,
276        exec_ctx: Option<ComponentExecCtx>,
277        component_ref: Option<String>,
278        provider_core_component: bool,
279    ) -> Result<Self> {
280        let default_env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
281        Ok(Self {
282            pack_id,
283            config,
284            http_client,
285            default_env,
286            session_store,
287            state_store,
288            mocks,
289            secrets,
290            oauth_config,
291            oauth_host: OAuthBrokerHost::default(),
292            exec_ctx,
293            component_ref,
294            provider_core_component,
295        })
296    }
297
298    fn instantiate_component_result(
299        linker: &mut Linker<ComponentState>,
300        store: &mut Store<ComponentState>,
301        component: &Component,
302        ctx: &ComponentExecCtx,
303        component_ref: &str,
304        operation: &str,
305        input_json: &str,
306    ) -> Result<InvokeResult> {
307        let pre_instance = linker.instantiate_pre(component)?;
308        match component_api::v0_6::ComponentPre::new(pre_instance) {
309            Ok(pre) => {
310                let envelope = component_api::envelope_v0_6(ctx, component_ref, input_json)?;
311                let operation_owned = operation.to_string();
312                let result = block_on(async {
313                    let bindings = pre.instantiate_async(&mut *store).await?;
314                    let node = bindings.greentic_component_node();
315                    node.call_invoke(&mut *store, &operation_owned, &envelope)
316                })?;
317                component_api::invoke_result_from_v0_6(result)
318            }
319            Err(err_v06) => {
320                if !is_missing_node_export(&err_v06, "0.6.0") {
321                    return Err(err_v06.into());
322                }
323                let pre_instance = linker.instantiate_pre(component)?;
324                match component_api::v0_5::ComponentPre::new(pre_instance) {
325                    Ok(pre) => {
326                        let result = block_on(async {
327                            let bindings = pre.instantiate_async(&mut *store).await?;
328                            let node = bindings.greentic_component_node();
329                            let ctx_v05 = component_api::exec_ctx_v0_5(ctx);
330                            let operation_owned = operation.to_string();
331                            let input_owned = input_json.to_string();
332                            node.call_invoke(&mut *store, &ctx_v05, &operation_owned, &input_owned)
333                        })?;
334                        Ok(component_api::invoke_result_from_v0_5(result))
335                    }
336                    Err(err) => {
337                        if !is_missing_node_export(&err, "0.5.0") {
338                            return Err(err.into());
339                        }
340                        let pre_instance = linker.instantiate_pre(component)?;
341                        match component_api::v0_4::ComponentPre::new(pre_instance) {
342                            Ok(pre) => {
343                                let result = block_on(async {
344                                    let bindings = pre.instantiate_async(&mut *store).await?;
345                                    let node = bindings.greentic_component_node();
346                                    let ctx_v04 = component_api::exec_ctx_v0_4(ctx);
347                                    let operation_owned = operation.to_string();
348                                    let input_owned = input_json.to_string();
349                                    node.call_invoke(
350                                        &mut *store,
351                                        &ctx_v04,
352                                        &operation_owned,
353                                        &input_owned,
354                                    )
355                                })?;
356                                Ok(component_api::invoke_result_from_v0_4(result))
357                            }
358                            Err(err_v04) => {
359                                if is_missing_node_export(&err_v04, "0.4.0") {
360                                    Self::try_v06_runtime(linker, store, component, input_json)
361                                } else {
362                                    Err(err_v04.into())
363                                }
364                            }
365                        }
366                    }
367                }
368            }
369        }
370    }
371
372    /// Fallback for v0.6 components that export `component-runtime::run(input, state)`
373    /// instead of the legacy `node::invoke(ctx, op, input)`.
374    fn try_v06_runtime(
375        linker: &mut Linker<ComponentState>,
376        store: &mut Store<ComponentState>,
377        component: &Component,
378        input_json: &str,
379    ) -> Result<InvokeResult> {
380        let pre_instance = linker.instantiate_pre(component)?;
381        let pre = component_api::v0_6_runtime::ComponentV0V6RuntimePre::new(pre_instance).map_err(
382            |err| err.context("component exports neither node@0.5/0.4 nor component-runtime@0.6"),
383        )?;
384
385        let result = block_on(async {
386            let bindings = pre.instantiate_async(&mut *store).await?;
387            let runtime = bindings.greentic_component_component_runtime();
388
389            // Encode input as CBOR — the component's run() expects CBOR bytes.
390            let input_value: Value = serde_json::from_str(input_json).unwrap_or(Value::Null);
391            let input_cbor =
392                serde_cbor::to_vec(&input_value).context("encode input as CBOR for v0.6")?;
393            let empty_state = serde_cbor::to_vec(&Value::Object(Default::default()))
394                .context("encode empty state")?;
395
396            let run_result = runtime
397                .call_run(&mut *store, &input_cbor, &empty_state)
398                .map_err(|err| err.context("v0.6 component-runtime::run call failed"))?;
399
400            // Decode output CBOR to JSON.
401            let output_value: Value = serde_cbor::from_slice(&run_result.output)
402                .context("decode v0.6 run output CBOR")?;
403            let output_json = serde_json::to_string(&output_value)
404                .context("serialize v0.6 run output to JSON")?;
405
406            Ok::<_, anyhow::Error>(output_json)
407        })?;
408
409        Ok(InvokeResult::Ok(result))
410    }
411
412    fn convert_invoke_result(result: InvokeResult) -> Result<Value> {
413        match result {
414            InvokeResult::Ok(body) => {
415                if body.is_empty() {
416                    return Ok(Value::Null);
417                }
418                serde_json::from_str(&body).or_else(|_| Ok(Value::String(body)))
419            }
420            InvokeResult::Err(NodeError {
421                code,
422                message,
423                retryable,
424                backoff_ms,
425                details,
426            }) => {
427                let mut obj = serde_json::Map::new();
428                obj.insert("ok".into(), Value::Bool(false));
429                let mut error = serde_json::Map::new();
430                error.insert("code".into(), Value::String(code));
431                error.insert("message".into(), Value::String(message));
432                error.insert("retryable".into(), Value::Bool(retryable));
433                if let Some(backoff) = backoff_ms {
434                    error.insert("backoff_ms".into(), Value::Number(backoff.into()));
435                }
436                if let Some(details) = details {
437                    error.insert(
438                        "details".into(),
439                        serde_json::from_str(&details).unwrap_or(Value::String(details)),
440                    );
441                }
442                obj.insert("error".into(), Value::Object(error));
443                Ok(Value::Object(obj))
444            }
445        }
446    }
447
448    /// Build a `TenantCtx` for secrets lookups that includes the team from the
449    /// execution context. `config.tenant_ctx()` only populates env + tenant;
450    /// without this, secrets scoped to a specific team are unreachable.
451    fn secrets_tenant_ctx(&self) -> TypesTenantCtx {
452        let mut ctx = self.config.tenant_ctx();
453        if let Some(exec_ctx) = self.exec_ctx.as_ref()
454            && let Some(team) = exec_ctx.tenant.team.as_ref()
455            && let Ok(team_id) = TeamId::from_str(team)
456        {
457            ctx = ctx.with_team(Some(team_id));
458        }
459        ctx
460    }
461
462    pub fn get_secret(&self, key: &str) -> Result<String> {
463        if provider_core_only::is_enabled() {
464            bail!(provider_core_only::blocked_message("secrets"))
465        }
466        if !self.config.secrets_policy.is_allowed(key) {
467            bail!("secret {key} is not permitted by bindings policy");
468        }
469        if let Some(mock) = &self.mocks
470            && let Some(value) = mock.secrets_lookup(key)
471        {
472            return Ok(value);
473        }
474        let ctx = self.secrets_tenant_ctx();
475        let canonical_key = canonicalize_secret_key(key);
476        let bytes = read_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key)
477            .context("failed to read secret from manager")?;
478        let value = String::from_utf8(bytes).context("secret value is not valid UTF-8")?;
479        Ok(value)
480    }
481
482    fn allows_secret_write_in_provider_core_only(&self) -> bool {
483        self.provider_core_component || self.component_ref.is_none()
484    }
485
486    fn tenant_ctx_from_v1(&self, ctx: Option<StateTenantCtx>) -> Result<TypesTenantCtx> {
487        let tenant_raw = ctx
488            .as_ref()
489            .map(|ctx| ctx.tenant.clone())
490            .or_else(|| self.exec_ctx.as_ref().map(|ctx| ctx.tenant.tenant.clone()))
491            .unwrap_or_else(|| self.config.tenant.clone());
492        let env_raw = ctx
493            .as_ref()
494            .map(|ctx| ctx.env.clone())
495            .unwrap_or_else(|| self.default_env.clone());
496        let tenant_id = TenantId::from_str(&tenant_raw)
497            .with_context(|| format!("invalid tenant id `{tenant_raw}`"))?;
498        let env_id = EnvId::from_str(&env_raw)
499            .unwrap_or_else(|_| EnvId::from_str("local").expect("default env must be valid"));
500        let mut tenant_ctx = TypesTenantCtx::new(env_id, tenant_id);
501        if let Some(exec_ctx) = self.exec_ctx.as_ref() {
502            if let Some(team) = exec_ctx.tenant.team.as_ref() {
503                let team_id =
504                    TeamId::from_str(team).with_context(|| format!("invalid team id `{team}`"))?;
505                tenant_ctx = tenant_ctx.with_team(Some(team_id));
506            }
507            if let Some(user) = exec_ctx.tenant.user.as_ref() {
508                let user_id =
509                    UserId::from_str(user).with_context(|| format!("invalid user id `{user}`"))?;
510                tenant_ctx = tenant_ctx.with_user(Some(user_id));
511            }
512            tenant_ctx = tenant_ctx.with_flow(exec_ctx.flow_id.clone());
513            if let Some(node) = exec_ctx.node_id.as_ref() {
514                tenant_ctx = tenant_ctx.with_node(node.clone());
515            }
516            if let Some(session) = exec_ctx.tenant.correlation_id.as_ref() {
517                tenant_ctx = tenant_ctx.with_session(session.clone());
518            }
519            tenant_ctx.trace_id = exec_ctx.tenant.trace_id.clone();
520        }
521
522        if let Some(ctx) = ctx {
523            if let Some(team) = ctx.team.or(ctx.team_id) {
524                let team_id =
525                    TeamId::from_str(&team).with_context(|| format!("invalid team id `{team}`"))?;
526                tenant_ctx = tenant_ctx.with_team(Some(team_id));
527            }
528            if let Some(user) = ctx.user.or(ctx.user_id) {
529                let user_id =
530                    UserId::from_str(&user).with_context(|| format!("invalid user id `{user}`"))?;
531                tenant_ctx = tenant_ctx.with_user(Some(user_id));
532            }
533            if let Some(flow) = ctx.flow_id {
534                tenant_ctx = tenant_ctx.with_flow(flow);
535            }
536            if let Some(node) = ctx.node_id {
537                tenant_ctx = tenant_ctx.with_node(node);
538            }
539            if let Some(provider) = ctx.provider_id {
540                tenant_ctx = tenant_ctx.with_provider(provider);
541            }
542            if let Some(session) = ctx.session_id {
543                tenant_ctx = tenant_ctx.with_session(session);
544            }
545            tenant_ctx.trace_id = ctx.trace_id;
546        }
547        Ok(tenant_ctx)
548    }
549
550    fn send_http_request(
551        &mut self,
552        req: HttpRequest,
553        opts: Option<HttpRequestOptionsV1_1>,
554        _ctx: Option<HttpTenantCtx>,
555    ) -> Result<HttpResponse, HttpClientError> {
556        if !self.config.http_enabled {
557            return Err(HttpClientError {
558                code: "denied".into(),
559                message: "http client disabled by policy".into(),
560            });
561        }
562
563        let mut mock_state = None;
564        let raw_body = req.body.clone();
565        if let Some(mock) = &self.mocks
566            && let Ok(meta) = HttpMockRequest::new(&req.method, &req.url, raw_body.as_deref())
567        {
568            match mock.http_begin(&meta) {
569                HttpDecision::Mock(response) => {
570                    let headers = response
571                        .headers
572                        .iter()
573                        .map(|(k, v)| (k.clone(), v.clone()))
574                        .collect();
575                    return Ok(HttpResponse {
576                        status: response.status,
577                        headers,
578                        body: response.body.clone().map(|b| b.into_bytes()),
579                    });
580                }
581                HttpDecision::Deny(reason) => {
582                    return Err(HttpClientError {
583                        code: "denied".into(),
584                        message: reason,
585                    });
586                }
587                HttpDecision::Passthrough { record } => {
588                    mock_state = Some((meta, record));
589                }
590            }
591        }
592
593        let method = req.method.parse().unwrap_or(reqwest::Method::GET);
594        let mut builder = self.http_client.request(method, &req.url);
595        for (key, value) in req.headers {
596            if let Ok(header) = reqwest::header::HeaderName::from_bytes(key.as_bytes())
597                && let Ok(header_value) = reqwest::header::HeaderValue::from_str(&value)
598            {
599                builder = builder.header(header, header_value);
600            }
601        }
602
603        if let Some(body) = raw_body.clone() {
604            builder = builder.body(body);
605        }
606
607        if let Some(opts) = opts {
608            if let Some(timeout_ms) = opts.timeout_ms {
609                builder = builder.timeout(Duration::from_millis(timeout_ms as u64));
610            }
611            if opts.allow_insecure == Some(true) {
612                warn!(url = %req.url, "allow-insecure not supported; using default TLS validation");
613            }
614            if let Some(follow_redirects) = opts.follow_redirects
615                && !follow_redirects
616            {
617                warn!(url = %req.url, "follow-redirects=false not supported; using default client behaviour");
618            }
619        }
620
621        let response = match builder.send() {
622            Ok(resp) => resp,
623            Err(err) => {
624                warn!(url = %req.url, error = %err, "http client request failed");
625                return Err(HttpClientError {
626                    code: "unavailable".into(),
627                    message: err.to_string(),
628                });
629            }
630        };
631
632        let status = response.status().as_u16();
633        let headers_vec = response
634            .headers()
635            .iter()
636            .map(|(k, v)| {
637                (
638                    k.as_str().to_string(),
639                    v.to_str().unwrap_or_default().to_string(),
640                )
641            })
642            .collect::<Vec<_>>();
643        let body_bytes = response.bytes().ok().map(|b| b.to_vec());
644
645        if let Some((meta, true)) = mock_state.take()
646            && let Some(mock) = &self.mocks
647        {
648            let recorded = HttpMockResponse::new(
649                status,
650                headers_vec.clone().into_iter().collect(),
651                body_bytes
652                    .as_ref()
653                    .map(|b| String::from_utf8_lossy(b).into_owned()),
654            );
655            mock.http_record(&meta, &recorded);
656        }
657
658        Ok(HttpResponse {
659            status,
660            headers: headers_vec,
661            body: body_bytes,
662        })
663    }
664}
665
666#[cfg(test)]
667mod canonicalize_tests {
668    use crate::secrets::canonicalize_secret_key;
669
670    #[test]
671    fn upper_snake_to_lower_snake() {
672        assert_eq!(
673            canonicalize_secret_key("TELEGRAM_BOT_TOKEN"),
674            "telegram_bot_token"
675        );
676    }
677
678    #[test]
679    fn trim_and_replace_non_alphanumeric() {
680        assert_eq!(
681            canonicalize_secret_key("  webex-bot-token  "),
682            "webex_bot_token"
683        );
684    }
685
686    #[test]
687    fn preserve_existing_lower_snake_with_extra_underscores() {
688        assert_eq!(canonicalize_secret_key("MiXeD__Case"), "mixed__case");
689    }
690}
691
692impl SecretsStoreHost for HostState {
693    fn get(&mut self, key: String) -> Result<Option<Vec<u8>>, SecretsError> {
694        if provider_core_only::is_enabled() {
695            warn!(secret = %key, "provider-core only mode enabled; blocking secrets store");
696            return Err(SecretsError::Denied);
697        }
698        if !self.config.secrets_policy.is_allowed(&key) {
699            return Err(SecretsError::Denied);
700        }
701        if let Some(mock) = &self.mocks
702            && let Some(value) = mock.secrets_lookup(&key)
703        {
704            return Ok(Some(value.into_bytes()));
705        }
706        let ctx = self.secrets_tenant_ctx();
707        let canonical_key = canonicalize_secret_key(&key);
708        match read_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key) {
709            Ok(bytes) => Ok(Some(bytes)),
710            Err(err) => {
711                warn!(secret = %key, canonical = %canonical_key, error = %err, "secret lookup failed");
712                Err(SecretsError::NotFound)
713            }
714        }
715    }
716}
717
718impl SecretsStoreHostV1_1 for HostState {
719    fn get(&mut self, key: String) -> Result<Option<Vec<u8>>, SecretsErrorV1_1> {
720        if provider_core_only::is_enabled() {
721            warn!(secret = %key, "provider-core only mode enabled; blocking secrets store");
722            return Err(SecretsErrorV1_1::Denied);
723        }
724        if !self.config.secrets_policy.is_allowed(&key) {
725            return Err(SecretsErrorV1_1::Denied);
726        }
727        if let Some(mock) = &self.mocks
728            && let Some(value) = mock.secrets_lookup(&key)
729        {
730            return Ok(Some(value.into_bytes()));
731        }
732        let ctx = self.secrets_tenant_ctx();
733        let canonical_key = canonicalize_secret_key(&key);
734        match read_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key) {
735            Ok(bytes) => Ok(Some(bytes)),
736            Err(err) => {
737                warn!(secret = %key, canonical = %canonical_key, error = %err, "secret lookup failed");
738                Err(SecretsErrorV1_1::NotFound)
739            }
740        }
741    }
742
743    fn put(&mut self, key: String, value: Vec<u8>) {
744        if key.trim().is_empty() {
745            warn!(secret = %key, "secret write blocked: empty key");
746            panic!("secret write denied for key {key}: invalid key");
747        }
748        if provider_core_only::is_enabled() && !self.allows_secret_write_in_provider_core_only() {
749            warn!(
750                secret = %key,
751                component = self.component_ref.as_deref().unwrap_or("<pack>"),
752                "provider-core only mode enabled; blocking secrets store write"
753            );
754            panic!("secret write denied for key {key}: provider-core-only mode");
755        }
756        if !self.config.secrets_policy.is_allowed(&key) {
757            warn!(secret = %key, "secret write denied by bindings policy");
758            panic!("secret write denied for key {key}: policy");
759        }
760        let ctx = self.secrets_tenant_ctx();
761        let canonical_key = canonicalize_secret_key(&key);
762        if let Err(err) =
763            write_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key, &value)
764        {
765            warn!(secret = %key, canonical = %canonical_key, error = %err, "secret write failed");
766            panic!("secret write failed for key {key}");
767        }
768    }
769}
770
771impl HttpClientHost for HostState {
772    fn send(
773        &mut self,
774        req: HttpRequest,
775        ctx: Option<HttpTenantCtx>,
776    ) -> Result<HttpResponse, HttpClientError> {
777        self.send_http_request(req, None, ctx)
778    }
779}
780
781impl HttpClientHostV1_1 for HostState {
782    fn send(
783        &mut self,
784        req: HttpRequestV1_1,
785        opts: Option<HttpRequestOptionsV1_1>,
786        ctx: Option<HttpTenantCtxV1_1>,
787    ) -> Result<HttpResponseV1_1, HttpClientErrorV1_1> {
788        let legacy_req = HttpRequest {
789            method: req.method,
790            url: req.url,
791            headers: req.headers,
792            body: req.body,
793        };
794        let legacy_ctx = ctx.map(|ctx| HttpTenantCtx {
795            env: ctx.env,
796            tenant: ctx.tenant,
797            tenant_id: ctx.tenant_id,
798            team: ctx.team,
799            team_id: ctx.team_id,
800            user: ctx.user,
801            user_id: ctx.user_id,
802            trace_id: ctx.trace_id,
803            correlation_id: ctx.correlation_id,
804            i18n_id: ctx.i18n_id,
805            attributes: ctx.attributes,
806            session_id: ctx.session_id,
807            flow_id: ctx.flow_id,
808            node_id: ctx.node_id,
809            provider_id: ctx.provider_id,
810            deadline_ms: ctx.deadline_ms,
811            attempt: ctx.attempt,
812            idempotency_key: ctx.idempotency_key,
813            impersonation: ctx.impersonation.map(|imp| http_types_v1_0::Impersonation {
814                actor_id: imp.actor_id,
815                reason: imp.reason,
816            }),
817        });
818
819        self.send_http_request(legacy_req, opts, legacy_ctx)
820            .map(|resp| HttpResponseV1_1 {
821                status: resp.status,
822                headers: resp.headers,
823                body: resp.body,
824            })
825            .map_err(|err| HttpClientErrorV1_1 {
826                code: err.code,
827                message: err.message,
828            })
829    }
830}
831
832impl StateStoreHost for HostState {
833    fn read(
834        &mut self,
835        key: HostStateKey,
836        ctx: Option<StateTenantCtx>,
837    ) -> Result<Vec<u8>, StateError> {
838        let store = match self.state_store.as_ref() {
839            Some(store) => store.clone(),
840            None => {
841                return Err(StateError {
842                    code: "unavailable".into(),
843                    message: "state store not configured".into(),
844                });
845            }
846        };
847        let tenant_ctx = match self.tenant_ctx_from_v1(ctx) {
848            Ok(ctx) => ctx,
849            Err(err) => {
850                return Err(StateError {
851                    code: "invalid-ctx".into(),
852                    message: err.to_string(),
853                });
854            }
855        };
856        #[cfg(feature = "fault-injection")]
857        {
858            let exec_ctx = self.exec_ctx.as_ref();
859            let flow_id = exec_ctx
860                .map(|ctx| ctx.flow_id.as_str())
861                .unwrap_or("unknown");
862            let node_id = exec_ctx.and_then(|ctx| ctx.node_id.as_deref());
863            let attempt = exec_ctx.map(|ctx| ctx.tenant.attempt).unwrap_or(1);
864            let fault_ctx = FaultContext {
865                pack_id: self.pack_id.as_str(),
866                flow_id,
867                node_id,
868                attempt,
869            };
870            if let Err(err) = maybe_fail(FaultPoint::StateRead, fault_ctx) {
871                return Err(StateError {
872                    code: "internal".into(),
873                    message: err.to_string(),
874                });
875            }
876        }
877        let key = StoreStateKey::from(key);
878        match store.get_json(&tenant_ctx, STATE_PREFIX, &key, None) {
879            Ok(Some(value)) => Ok(serde_json::to_vec(&value).unwrap_or_else(|_| Vec::new())),
880            Ok(None) => Err(StateError {
881                code: "not_found".into(),
882                message: "state key not found".into(),
883            }),
884            Err(err) => Err(StateError {
885                code: "internal".into(),
886                message: err.to_string(),
887            }),
888        }
889    }
890
891    fn write(
892        &mut self,
893        key: HostStateKey,
894        bytes: Vec<u8>,
895        ctx: Option<StateTenantCtx>,
896    ) -> Result<StateOpAck, StateError> {
897        let store = match self.state_store.as_ref() {
898            Some(store) => store.clone(),
899            None => {
900                return Err(StateError {
901                    code: "unavailable".into(),
902                    message: "state store not configured".into(),
903                });
904            }
905        };
906        let tenant_ctx = match self.tenant_ctx_from_v1(ctx) {
907            Ok(ctx) => ctx,
908            Err(err) => {
909                return Err(StateError {
910                    code: "invalid-ctx".into(),
911                    message: err.to_string(),
912                });
913            }
914        };
915        #[cfg(feature = "fault-injection")]
916        {
917            let exec_ctx = self.exec_ctx.as_ref();
918            let flow_id = exec_ctx
919                .map(|ctx| ctx.flow_id.as_str())
920                .unwrap_or("unknown");
921            let node_id = exec_ctx.and_then(|ctx| ctx.node_id.as_deref());
922            let attempt = exec_ctx.map(|ctx| ctx.tenant.attempt).unwrap_or(1);
923            let fault_ctx = FaultContext {
924                pack_id: self.pack_id.as_str(),
925                flow_id,
926                node_id,
927                attempt,
928            };
929            if let Err(err) = maybe_fail(FaultPoint::StateWrite, fault_ctx) {
930                return Err(StateError {
931                    code: "internal".into(),
932                    message: err.to_string(),
933                });
934            }
935        }
936        let key = StoreStateKey::from(key);
937        let value = serde_json::from_slice(&bytes)
938            .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).to_string()));
939        match store.set_json(&tenant_ctx, STATE_PREFIX, &key, None, &value, None) {
940            Ok(()) => Ok(StateOpAck::Ok),
941            Err(err) => Err(StateError {
942                code: "internal".into(),
943                message: err.to_string(),
944            }),
945        }
946    }
947
948    fn delete(
949        &mut self,
950        key: HostStateKey,
951        ctx: Option<StateTenantCtx>,
952    ) -> Result<StateOpAck, StateError> {
953        let store = match self.state_store.as_ref() {
954            Some(store) => store.clone(),
955            None => {
956                return Err(StateError {
957                    code: "unavailable".into(),
958                    message: "state store not configured".into(),
959                });
960            }
961        };
962        let tenant_ctx = match self.tenant_ctx_from_v1(ctx) {
963            Ok(ctx) => ctx,
964            Err(err) => {
965                return Err(StateError {
966                    code: "invalid-ctx".into(),
967                    message: err.to_string(),
968                });
969            }
970        };
971        let key = StoreStateKey::from(key);
972        match store.del(&tenant_ctx, STATE_PREFIX, &key) {
973            Ok(_) => Ok(StateOpAck::Ok),
974            Err(err) => Err(StateError {
975                code: "internal".into(),
976                message: err.to_string(),
977            }),
978        }
979    }
980}
981
982impl TelemetryLoggerHost for HostState {
983    fn log(
984        &mut self,
985        span: TelemetrySpanContext,
986        fields: Vec<(String, String)>,
987        _ctx: Option<TelemetryTenantCtx>,
988    ) -> Result<TelemetryAck, TelemetryError> {
989        if let Some(mock) = &self.mocks
990            && mock.telemetry_drain(&[("span_json", span.flow_id.as_str())])
991        {
992            return Ok(TelemetryAck::Ok);
993        }
994        let mut map = serde_json::Map::new();
995        for (k, v) in fields {
996            map.insert(k, Value::String(v));
997        }
998        tracing::info!(
999            tenant = %span.tenant,
1000            flow_id = %span.flow_id,
1001            node = ?span.node_id,
1002            provider = %span.provider,
1003            fields = %serde_json::Value::Object(map.clone()),
1004            "telemetry log from pack"
1005        );
1006        Ok(TelemetryAck::Ok)
1007    }
1008}
1009
1010impl RunnerHostHttp for HostState {
1011    fn request(
1012        &mut self,
1013        method: String,
1014        url: String,
1015        headers: Vec<String>,
1016        body: Option<Vec<u8>>,
1017    ) -> Result<Vec<u8>, String> {
1018        let req = HttpRequest {
1019            method,
1020            url,
1021            headers: headers
1022                .chunks(2)
1023                .filter_map(|chunk| {
1024                    if chunk.len() == 2 {
1025                        Some((chunk[0].clone(), chunk[1].clone()))
1026                    } else {
1027                        None
1028                    }
1029                })
1030                .collect(),
1031            body,
1032        };
1033        match HttpClientHost::send(self, req, None) {
1034            Ok(resp) => Ok(resp.body.unwrap_or_default()),
1035            Err(err) => Err(err.message),
1036        }
1037    }
1038}
1039
1040impl RunnerHostKv for HostState {
1041    fn get(&mut self, _ns: String, _key: String) -> Option<String> {
1042        None
1043    }
1044
1045    fn put(&mut self, _ns: String, _key: String, _val: String) {}
1046}
1047
1048enum ManifestLoad {
1049    New {
1050        manifest: Box<greentic_types::PackManifest>,
1051        flows: PackFlows,
1052    },
1053    Legacy {
1054        manifest: Box<legacy_pack::PackManifest>,
1055        flows: PackFlows,
1056    },
1057}
1058
1059fn load_manifest_and_flows(path: &Path) -> Result<ManifestLoad> {
1060    let mut archive = ZipArchive::new(File::open(path)?)
1061        .with_context(|| format!("{} is not a valid gtpack", path.display()))?;
1062    let bytes = read_entry(&mut archive, "manifest.cbor")
1063        .with_context(|| format!("missing manifest.cbor in {}", path.display()))?;
1064    match decode_pack_manifest(&bytes) {
1065        Ok(manifest) => {
1066            let cache = PackFlows::from_manifest(manifest.clone());
1067            Ok(ManifestLoad::New {
1068                manifest: Box::new(manifest),
1069                flows: cache,
1070            })
1071        }
1072        Err(err) => {
1073            tracing::debug!(
1074                error = %err,
1075                pack = %path.display(),
1076                "decode_pack_manifest failed for archive; falling back to legacy manifest"
1077            );
1078            let legacy: legacy_pack::PackManifest = serde_cbor::from_slice(&bytes)
1079                .context("failed to decode legacy pack manifest from manifest.cbor")?;
1080            let flows = load_legacy_flows_from_archive(&mut archive, &legacy)?;
1081            Ok(ManifestLoad::Legacy {
1082                manifest: Box::new(legacy),
1083                flows,
1084            })
1085        }
1086    }
1087}
1088
1089fn load_manifest_and_flows_from_dir(root: &Path) -> Result<ManifestLoad> {
1090    let manifest_path = root.join("manifest.cbor");
1091    let bytes = std::fs::read(&manifest_path)
1092        .with_context(|| format!("missing manifest.cbor in {}", root.display()))?;
1093    match decode_pack_manifest(&bytes) {
1094        Ok(manifest) => {
1095            let cache = PackFlows::from_manifest(manifest.clone());
1096            Ok(ManifestLoad::New {
1097                manifest: Box::new(manifest),
1098                flows: cache,
1099            })
1100        }
1101        Err(err) => {
1102            tracing::debug!(
1103                error = %err,
1104                pack = %root.display(),
1105                "decode_pack_manifest failed for materialized pack; trying legacy manifest"
1106            );
1107            let legacy: legacy_pack::PackManifest = serde_cbor::from_slice(&bytes)
1108                .context("failed to decode legacy pack manifest from manifest.cbor")?;
1109            let flows = load_legacy_flows_from_dir(root, &legacy)?;
1110            Ok(ManifestLoad::Legacy {
1111                manifest: Box::new(legacy),
1112                flows,
1113            })
1114        }
1115    }
1116}
1117
1118fn load_legacy_flows_from_dir(
1119    root: &Path,
1120    manifest: &legacy_pack::PackManifest,
1121) -> Result<PackFlows> {
1122    build_legacy_flows(manifest, |rel_path| {
1123        let path = root.join(rel_path);
1124        std::fs::read(&path).with_context(|| format!("missing flow json {}", path.display()))
1125    })
1126}
1127
1128fn load_legacy_flows_from_archive(
1129    archive: &mut ZipArchive<File>,
1130    manifest: &legacy_pack::PackManifest,
1131) -> Result<PackFlows> {
1132    build_legacy_flows(manifest, |rel_path| {
1133        read_entry(archive, rel_path).with_context(|| format!("missing flow json {}", rel_path))
1134    })
1135}
1136
1137fn build_legacy_flows(
1138    manifest: &legacy_pack::PackManifest,
1139    mut read_json: impl FnMut(&str) -> Result<Vec<u8>>,
1140) -> Result<PackFlows> {
1141    let mut flows = HashMap::new();
1142    let mut descriptors = Vec::new();
1143
1144    for entry in &manifest.flows {
1145        let bytes = read_json(&entry.file_json)
1146            .with_context(|| format!("missing flow json {}", entry.file_json))?;
1147        let doc = parse_flow_doc_with_legacy_aliases(&bytes)?;
1148        let normalized = normalize_flow_doc(doc);
1149        let flow_ir = flow_doc_to_ir(normalized)?;
1150        let flow = flow_ir_to_flow(flow_ir)?;
1151
1152        descriptors.push(FlowDescriptor {
1153            id: entry.id.clone(),
1154            flow_type: entry.kind.clone(),
1155            pack_id: manifest.meta.pack_id.clone(),
1156            profile: manifest.meta.pack_id.clone(),
1157            version: manifest.meta.version.to_string(),
1158            description: None,
1159        });
1160        flows.insert(entry.id.clone(), flow);
1161    }
1162
1163    let mut entry_flows = manifest.meta.entry_flows.clone();
1164    if entry_flows.is_empty() {
1165        entry_flows = manifest.flows.iter().map(|f| f.id.clone()).collect();
1166    }
1167    let metadata = PackMetadata {
1168        pack_id: manifest.meta.pack_id.clone(),
1169        version: manifest.meta.version.to_string(),
1170        entry_flows,
1171        secret_requirements: Vec::new(),
1172    };
1173
1174    Ok(PackFlows {
1175        descriptors,
1176        flows,
1177        metadata,
1178    })
1179}
1180
1181fn parse_flow_doc_with_legacy_aliases(bytes: &[u8]) -> Result<FlowDoc> {
1182    let mut value: Value =
1183        serde_json::from_slice(bytes).context("failed to decode flow doc JSON")?;
1184    if let Some(map) = value.as_object_mut()
1185        && !map.contains_key("type")
1186        && let Some(flow_type) = map.remove("flow_type")
1187    {
1188        map.insert("type".to_string(), flow_type);
1189    }
1190    serde_json::from_value(value).context("failed to decode flow doc structure")
1191}
1192
1193pub struct ComponentState {
1194    pub host: HostState,
1195    wasi_ctx: WasiCtx,
1196    wasi_tls_ctx: WasiTlsCtx,
1197    wasi_http_ctx: WasiHttpCtx,
1198    resource_table: ResourceTable,
1199}
1200
1201impl ComponentState {
1202    pub fn new(host: HostState, policy: Arc<RunnerWasiPolicy>) -> Result<Self> {
1203        let wasi_ctx = policy
1204            .instantiate()
1205            .context("failed to build WASI context")?;
1206        Ok(Self {
1207            host,
1208            wasi_ctx,
1209            wasi_tls_ctx: WasiTlsCtxBuilder::new().build(),
1210            wasi_http_ctx: WasiHttpCtx::new(),
1211            resource_table: ResourceTable::new(),
1212        })
1213    }
1214
1215    fn host_mut(&mut self) -> &mut HostState {
1216        &mut self.host
1217    }
1218
1219    fn should_cancel_host(&mut self) -> bool {
1220        false
1221    }
1222
1223    fn yield_now_host(&mut self) {
1224        // no-op cooperative yield
1225    }
1226}
1227
1228impl component_api::v0_4::greentic::component::control::Host for ComponentState {
1229    fn should_cancel(&mut self) -> bool {
1230        self.should_cancel_host()
1231    }
1232
1233    fn yield_now(&mut self) {
1234        self.yield_now_host();
1235    }
1236}
1237
1238impl component_api::v0_5::greentic::component::control::Host for ComponentState {
1239    fn should_cancel(&mut self) -> bool {
1240        self.should_cancel_host()
1241    }
1242
1243    fn yield_now(&mut self) {
1244        self.yield_now_host();
1245    }
1246}
1247
1248fn add_component_control_instance(
1249    linker: &mut Linker<ComponentState>,
1250    name: &str,
1251) -> wasmtime::Result<()> {
1252    let mut inst = linker.instance(name)?;
1253    inst.func_wrap(
1254        "should-cancel",
1255        |mut caller: StoreContextMut<'_, ComponentState>, (): ()| {
1256            let host = caller.data_mut();
1257            Ok((host.should_cancel_host(),))
1258        },
1259    )?;
1260    inst.func_wrap(
1261        "yield-now",
1262        |mut caller: StoreContextMut<'_, ComponentState>, (): ()| {
1263            let host = caller.data_mut();
1264            host.yield_now_host();
1265            Ok(())
1266        },
1267    )?;
1268    Ok(())
1269}
1270
1271fn add_component_control_to_linker(linker: &mut Linker<ComponentState>) -> wasmtime::Result<()> {
1272    add_component_control_instance(linker, "greentic:component/control@0.5.0")?;
1273    add_component_control_instance(linker, "greentic:component/control@0.4.0")?;
1274    Ok(())
1275}
1276
1277pub fn register_all(linker: &mut Linker<ComponentState>, allow_state_store: bool) -> Result<()> {
1278    add_wasi_to_linker(linker)?;
1279
1280    // Add wasi-tls types and turn on the feature in linker
1281    let mut opts = LinkOptions::default();
1282    opts.tls(true);
1283    wasmtime_wasi_tls::add_to_linker(linker, &mut opts, |h: &mut ComponentState| {
1284        WasiTls::new(&h.wasi_tls_ctx, &mut h.resource_table)
1285    })?;
1286
1287    // Add wasi-http types and turn on the feature in linker
1288    add_wasi_http_to_linker(linker)?;
1289
1290    add_all_v1_to_linker(
1291        linker,
1292        HostFns {
1293            http_client_v1_1: Some(|state: &mut ComponentState| state.host_mut()),
1294            http_client: Some(|state: &mut ComponentState| state.host_mut()),
1295            oauth_broker: None,
1296            runner_host_http: Some(|state: &mut ComponentState| state.host_mut()),
1297            runner_host_kv: Some(|state: &mut ComponentState| state.host_mut()),
1298            telemetry_logger: Some(|state: &mut ComponentState| state.host_mut()),
1299            state_store: allow_state_store.then_some(|state: &mut ComponentState| state.host_mut()),
1300            secrets_store_v1_1: Some(|state: &mut ComponentState| state.host_mut()),
1301            secrets_store: None,
1302        },
1303    )?;
1304    add_http_client_client_world_aliases(linker)?;
1305    Ok(())
1306}
1307
1308fn add_http_client_client_world_aliases(linker: &mut Linker<ComponentState>) -> Result<()> {
1309    let mut inst_v1_1 = linker.instance("greentic:http/client@1.1.0")?;
1310    inst_v1_1.func_wrap(
1311        "send",
1312        move |mut caller: StoreContextMut<'_, ComponentState>,
1313              (req, opts, ctx): (
1314            http_client_client_alias::Request,
1315            Option<http_client_client_alias::RequestOptions>,
1316            Option<http_client_client_alias::TenantCtx>,
1317        )| {
1318            let host = caller.data_mut().host_mut();
1319            let result = HttpClientHostV1_1::send(
1320                host,
1321                alias_request_to_host(req),
1322                opts.map(alias_request_options_to_host),
1323                ctx.map(alias_tenant_ctx_to_host),
1324            );
1325            Ok((match result {
1326                Ok(resp) => Ok(alias_response_from_host(resp)),
1327                Err(err) => Err(alias_error_from_host(err)),
1328            },))
1329        },
1330    )?;
1331    let mut inst_v1_0 = linker.instance("greentic:http/client@1.0.0")?;
1332    inst_v1_0.func_wrap(
1333        "send",
1334        move |mut caller: StoreContextMut<'_, ComponentState>,
1335              (req, ctx): (
1336            host_http_client::Request,
1337            Option<host_http_client::TenantCtx>,
1338        )| {
1339            let host = caller.data_mut().host_mut();
1340            let result = HttpClientHost::send(host, req, ctx);
1341            Ok((result,))
1342        },
1343    )?;
1344    Ok(())
1345}
1346
1347fn alias_request_to_host(req: http_client_client_alias::Request) -> host_http_client::RequestV1_1 {
1348    host_http_client::RequestV1_1 {
1349        method: req.method,
1350        url: req.url,
1351        headers: req.headers,
1352        body: req.body,
1353    }
1354}
1355
1356fn alias_request_options_to_host(
1357    opts: http_client_client_alias::RequestOptions,
1358) -> host_http_client::RequestOptionsV1_1 {
1359    host_http_client::RequestOptionsV1_1 {
1360        timeout_ms: opts.timeout_ms,
1361        allow_insecure: opts.allow_insecure,
1362        follow_redirects: opts.follow_redirects,
1363    }
1364}
1365
1366fn alias_tenant_ctx_to_host(
1367    ctx: http_client_client_alias::TenantCtx,
1368) -> host_http_client::TenantCtxV1_1 {
1369    host_http_client::TenantCtxV1_1 {
1370        env: ctx.env,
1371        tenant: ctx.tenant,
1372        tenant_id: ctx.tenant_id,
1373        team: ctx.team,
1374        team_id: ctx.team_id,
1375        user: ctx.user,
1376        user_id: ctx.user_id,
1377        trace_id: ctx.trace_id,
1378        correlation_id: ctx.correlation_id,
1379        i18n_id: ctx.i18n_id,
1380        attributes: ctx.attributes,
1381        session_id: ctx.session_id,
1382        flow_id: ctx.flow_id,
1383        node_id: ctx.node_id,
1384        provider_id: ctx.provider_id,
1385        deadline_ms: ctx.deadline_ms,
1386        attempt: ctx.attempt,
1387        idempotency_key: ctx.idempotency_key,
1388        impersonation: ctx.impersonation.map(|imp| http_types_v1_1::Impersonation {
1389            actor_id: imp.actor_id,
1390            reason: imp.reason,
1391        }),
1392    }
1393}
1394
1395fn alias_response_from_host(
1396    resp: host_http_client::ResponseV1_1,
1397) -> http_client_client_alias::Response {
1398    http_client_client_alias::Response {
1399        status: resp.status,
1400        headers: resp.headers,
1401        body: resp.body,
1402    }
1403}
1404
1405fn alias_error_from_host(
1406    err: host_http_client::HttpClientErrorV1_1,
1407) -> http_client_client_alias::HostError {
1408    http_client_client_alias::HostError {
1409        code: err.code,
1410        message: err.message,
1411    }
1412}
1413
1414impl OAuthHostContext for ComponentState {
1415    fn tenant_id(&self) -> &str {
1416        &self.host.config.tenant
1417    }
1418
1419    fn env(&self) -> &str {
1420        &self.host.default_env
1421    }
1422
1423    fn oauth_broker_host(&mut self) -> &mut OAuthBrokerHost {
1424        &mut self.host.oauth_host
1425    }
1426
1427    fn oauth_config(&self) -> Option<&OAuthBrokerConfig> {
1428        self.host.oauth_config.as_ref()
1429    }
1430}
1431
1432impl WasiView for ComponentState {
1433    fn ctx(&mut self) -> WasiCtxView<'_> {
1434        WasiCtxView {
1435            ctx: &mut self.wasi_ctx,
1436            table: &mut self.resource_table,
1437        }
1438    }
1439}
1440
1441impl WasiHttpView for ComponentState {
1442    fn http(&mut self) -> WasiHttpCtxView<'_> {
1443        WasiHttpCtxView {
1444            ctx: &mut self.wasi_http_ctx,
1445            table: &mut self.resource_table,
1446            hooks: Default::default(),
1447        }
1448    }
1449}
1450
1451#[allow(unsafe_code)]
1452unsafe impl Send for ComponentState {}
1453#[allow(unsafe_code)]
1454unsafe impl Sync for ComponentState {}
1455
1456impl PackRuntime {
1457    fn allows_state_store(&self, component_ref: &str) -> bool {
1458        if self.state_store.is_none() {
1459            return false;
1460        }
1461        if !self.config.state_store_policy.allow {
1462            return false;
1463        }
1464        let Some(manifest) = self.component_manifests.get(component_ref) else {
1465            // No manifest entry — allow state-store; Wasmtime rejects if not imported.
1466            return true;
1467        };
1468        // If manifest declares host.state capabilities, honour them.
1469        // If host.state is None (not declared in manifest), default to true so
1470        // components whose CBOR manifest omits the field still get state-store
1471        // linked — Wasmtime will reject at instantiation if not actually imported.
1472        manifest
1473            .capabilities
1474            .host
1475            .state
1476            .as_ref()
1477            .map(|caps| caps.read || caps.write)
1478            .unwrap_or(true)
1479    }
1480
1481    pub fn contains_component(&self, component_ref: &str) -> bool {
1482        self.components.contains_key(component_ref)
1483    }
1484
1485    /// Returns a clonable handle to the pack's state store, when one is
1486    /// configured. Used by the flow engine's built-in `state.get`/`state.set`
1487    /// operators which call into the same store that WASM components read
1488    /// through their `state.read`/`state.write` host imports.
1489    pub fn state_store_handle(&self) -> Option<crate::storage::DynStateStore> {
1490        self.state_store.clone()
1491    }
1492
1493    #[allow(clippy::too_many_arguments)]
1494    pub async fn load(
1495        path: impl AsRef<Path>,
1496        config: Arc<HostConfig>,
1497        mocks: Option<Arc<MockLayer>>,
1498        archive_source: Option<&Path>,
1499        session_store: Option<DynSessionStore>,
1500        state_store: Option<DynStateStore>,
1501        wasi_policy: Arc<RunnerWasiPolicy>,
1502        secrets: DynSecretsManager,
1503        oauth_config: Option<OAuthBrokerConfig>,
1504        verify_archive: bool,
1505        component_resolution: ComponentResolution,
1506    ) -> Result<Self> {
1507        let path = path.as_ref();
1508        let (_pack_root, safe_path) = normalize_pack_path(path)?;
1509        let path_meta = std::fs::metadata(&safe_path).ok();
1510        let is_dir = path_meta
1511            .as_ref()
1512            .map(|meta| meta.is_dir())
1513            .unwrap_or(false);
1514        let is_component = !is_dir
1515            && safe_path
1516                .extension()
1517                .and_then(|ext| ext.to_str())
1518                .map(|ext| ext.eq_ignore_ascii_case("wasm"))
1519                .unwrap_or(false);
1520        let archive_hint_path = if let Some(source) = archive_source {
1521            let (_, normalized) = normalize_pack_path(source)?;
1522            Some(normalized)
1523        } else if is_component || is_dir {
1524            None
1525        } else {
1526            Some(safe_path.clone())
1527        };
1528        let archive_hint = archive_hint_path.as_deref();
1529        if verify_archive {
1530            if let Some(verify_target) = archive_hint.and_then(|p| {
1531                std::fs::metadata(p)
1532                    .ok()
1533                    .filter(|meta| meta.is_file())
1534                    .map(|_| p)
1535            }) {
1536                verify::verify_pack(verify_target).await?;
1537                tracing::info!(pack_path = %verify_target.display(), "pack verification complete");
1538            } else {
1539                tracing::debug!("skipping archive verification (no archive source)");
1540            }
1541        }
1542        let engine = Engine::default();
1543        let engine_profile =
1544            EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
1545        let cache = CacheManager::new(CacheConfig::default(), engine_profile);
1546        let mut metadata = PackMetadata::fallback(&safe_path);
1547        let mut manifest = None;
1548        let mut legacy_manifest: Option<Box<legacy_pack::PackManifest>> = None;
1549        let mut flows = None;
1550        let materialized_root = component_resolution.materialized_root.clone().or_else(|| {
1551            if is_dir {
1552                Some(safe_path.clone())
1553            } else {
1554                None
1555            }
1556        });
1557        let (pack_assets_dir, assets_tempdir) =
1558            locate_pack_assets(materialized_root.as_deref(), archive_hint)?;
1559        let setup_yaml_exists = pack_assets_dir
1560            .as_ref()
1561            .map(|dir| dir.join("setup.yaml").is_file())
1562            .unwrap_or(false);
1563        tracing::info!(
1564            pack_root = %safe_path.display(),
1565            assets_setup_yaml_exists = setup_yaml_exists,
1566            "pack unpack metadata"
1567        );
1568
1569        if let Some(root) = materialized_root.as_ref() {
1570            match load_manifest_and_flows_from_dir(root) {
1571                Ok(ManifestLoad::New {
1572                    manifest: m,
1573                    flows: cache,
1574                }) => {
1575                    metadata = cache.metadata.clone();
1576                    manifest = Some(*m);
1577                    flows = Some(cache);
1578                }
1579                Ok(ManifestLoad::Legacy {
1580                    manifest: m,
1581                    flows: cache,
1582                }) => {
1583                    metadata = cache.metadata.clone();
1584                    legacy_manifest = Some(m);
1585                    flows = Some(cache);
1586                }
1587                Err(err) => {
1588                    warn!(error = %err, pack = %root.display(), "failed to parse materialized pack manifest");
1589                }
1590            }
1591        }
1592
1593        if manifest.is_none()
1594            && legacy_manifest.is_none()
1595            && let Some(archive_path) = archive_hint
1596        {
1597            let manifest_load = load_manifest_and_flows(archive_path).with_context(|| {
1598                format!(
1599                    "failed to load manifest.cbor from {}",
1600                    archive_path.display()
1601                )
1602            })?;
1603            match manifest_load {
1604                ManifestLoad::New {
1605                    manifest: m,
1606                    flows: cache,
1607                } => {
1608                    metadata = cache.metadata.clone();
1609                    manifest = Some(*m);
1610                    flows = Some(cache);
1611                }
1612                ManifestLoad::Legacy {
1613                    manifest: m,
1614                    flows: cache,
1615                } => {
1616                    metadata = cache.metadata.clone();
1617                    legacy_manifest = Some(m);
1618                    flows = Some(cache);
1619                }
1620            }
1621        }
1622        #[cfg(feature = "fault-injection")]
1623        {
1624            let fault_ctx = FaultContext {
1625                pack_id: metadata.pack_id.as_str(),
1626                flow_id: "unknown",
1627                node_id: None,
1628                attempt: 1,
1629            };
1630            maybe_fail(FaultPoint::PackResolve, fault_ctx)
1631                .map_err(|err| anyhow!(err.to_string()))?;
1632        }
1633        let mut pack_lock = None;
1634        for root in find_pack_lock_roots(&safe_path, is_dir, archive_hint) {
1635            pack_lock = load_pack_lock(&root)?;
1636            if pack_lock.is_some() {
1637                break;
1638            }
1639        }
1640        let component_sources_payload = if pack_lock.is_none() {
1641            if let Some(manifest) = manifest.as_ref() {
1642                manifest
1643                    .get_component_sources_v1()
1644                    .context("invalid component sources extension")?
1645            } else {
1646                None
1647            }
1648        } else {
1649            None
1650        };
1651        let component_sources = if let Some(lock) = pack_lock.as_ref() {
1652            Some(component_sources_table_from_pack_lock(
1653                lock,
1654                component_resolution.allow_missing_hash,
1655            )?)
1656        } else {
1657            component_sources_table(component_sources_payload.as_ref())?
1658        };
1659        let components = if is_component {
1660            let wasm_bytes = fs::read(&safe_path).await?;
1661            metadata = PackMetadata::from_wasm(&wasm_bytes)
1662                .unwrap_or_else(|| PackMetadata::fallback(&safe_path));
1663            let name = safe_path
1664                .file_stem()
1665                .map(|s| s.to_string_lossy().to_string())
1666                .unwrap_or_else(|| "component".to_string());
1667            let component = compile_component_with_cache(&cache, &engine, None, wasm_bytes).await?;
1668            let mut map = HashMap::new();
1669            map.insert(
1670                name.clone(),
1671                PackComponent {
1672                    name,
1673                    version: metadata.version.clone(),
1674                    component,
1675                },
1676            );
1677            map
1678        } else {
1679            let specs = component_specs(
1680                manifest.as_ref(),
1681                legacy_manifest.as_deref(),
1682                component_sources_payload.as_ref(),
1683                pack_lock.as_ref(),
1684            );
1685            if specs.is_empty() {
1686                HashMap::new()
1687            } else {
1688                let mut loaded = HashMap::new();
1689                let mut missing: HashSet<String> =
1690                    specs.iter().map(|spec| spec.id.clone()).collect();
1691                let mut searched = Vec::new();
1692
1693                if !component_resolution.overrides.is_empty() {
1694                    load_components_from_overrides(
1695                        &cache,
1696                        &engine,
1697                        &component_resolution.overrides,
1698                        &specs,
1699                        &mut missing,
1700                        &mut loaded,
1701                    )
1702                    .await?;
1703                    searched.push("override map".to_string());
1704                }
1705
1706                if let Some(component_sources) = component_sources.as_ref() {
1707                    load_components_from_sources(
1708                        &cache,
1709                        &engine,
1710                        component_sources,
1711                        &component_resolution,
1712                        &specs,
1713                        &mut missing,
1714                        &mut loaded,
1715                        materialized_root.as_deref(),
1716                        archive_hint,
1717                    )
1718                    .await?;
1719                    searched.push(format!("extension {}", EXT_COMPONENT_SOURCES_V1));
1720                }
1721
1722                if let Some(root) = materialized_root.as_ref() {
1723                    load_components_from_dir(
1724                        &cache,
1725                        &engine,
1726                        root,
1727                        &specs,
1728                        &mut missing,
1729                        &mut loaded,
1730                    )
1731                    .await?;
1732                    searched.push(format!("components dir {}", root.display()));
1733                }
1734
1735                if let Some(archive_path) = archive_hint {
1736                    load_components_from_archive(
1737                        &cache,
1738                        &engine,
1739                        archive_path,
1740                        &specs,
1741                        &mut missing,
1742                        &mut loaded,
1743                    )
1744                    .await?;
1745                    searched.push(format!("archive {}", archive_path.display()));
1746                }
1747
1748                if !missing.is_empty() {
1749                    let missing_list = missing.into_iter().collect::<Vec<_>>().join(", ");
1750                    let sources = if searched.is_empty() {
1751                        "no component sources".to_string()
1752                    } else {
1753                        searched.join(", ")
1754                    };
1755                    bail!(
1756                        "components missing: {}; looked in {}",
1757                        missing_list,
1758                        sources
1759                    );
1760                }
1761
1762                loaded
1763            }
1764        };
1765        let http_client = Arc::clone(&HTTP_CLIENT);
1766        let mut component_manifests = HashMap::new();
1767        if let Some(manifest) = manifest.as_ref() {
1768            for component in &manifest.components {
1769                component_manifests.insert(component.id.as_str().to_string(), component.clone());
1770            }
1771        }
1772        let mut pack_policy = (*wasi_policy).clone();
1773        if let Some(dir) = pack_assets_dir {
1774            tracing::debug!(path = %dir.display(), "preopening pack assets directory for WASI /assets");
1775            pack_policy =
1776                pack_policy.with_preopen(PreopenSpec::new(dir, "/assets").read_only(true));
1777        }
1778        let wasi_policy = Arc::new(pack_policy);
1779        Ok(Self {
1780            path: safe_path,
1781            archive_path: archive_hint.map(Path::to_path_buf),
1782            config,
1783            engine,
1784            metadata,
1785            manifest,
1786            legacy_manifest,
1787            component_manifests,
1788            mocks,
1789            flows,
1790            components,
1791            http_client,
1792            pre_cache: Mutex::new(HashMap::new()),
1793            session_store,
1794            state_store,
1795            wasi_policy,
1796            assets_tempdir,
1797            provider_registry: RwLock::new(None),
1798            secrets,
1799            oauth_config,
1800            cache,
1801        })
1802    }
1803
1804    pub async fn list_flows(&self) -> Result<Vec<FlowDescriptor>> {
1805        if let Some(cache) = &self.flows {
1806            return Ok(cache.descriptors.clone());
1807        }
1808        if let Some(manifest) = &self.manifest {
1809            let descriptors = manifest
1810                .flows
1811                .iter()
1812                .map(|flow| FlowDescriptor {
1813                    id: flow.id.as_str().to_string(),
1814                    flow_type: flow_kind_to_str(flow.kind).to_string(),
1815                    pack_id: manifest.pack_id.as_str().to_string(),
1816                    profile: manifest.pack_id.as_str().to_string(),
1817                    version: manifest.version.to_string(),
1818                    description: None,
1819                })
1820                .collect();
1821            return Ok(descriptors);
1822        }
1823        Ok(Vec::new())
1824    }
1825
1826    #[allow(dead_code)]
1827    pub async fn run_flow(
1828        &self,
1829        flow_id: &str,
1830        input: serde_json::Value,
1831    ) -> Result<serde_json::Value> {
1832        let pack = Arc::new(
1833            PackRuntime::load(
1834                &self.path,
1835                Arc::clone(&self.config),
1836                self.mocks.clone(),
1837                self.archive_path.as_deref(),
1838                self.session_store.clone(),
1839                self.state_store.clone(),
1840                Arc::clone(&self.wasi_policy),
1841                self.secrets.clone(),
1842                self.oauth_config.clone(),
1843                false,
1844                ComponentResolution::default(),
1845            )
1846            .await?,
1847        );
1848
1849        let engine = FlowEngine::new(vec![Arc::clone(&pack)], Arc::clone(&self.config)).await?;
1850        let retry_config = self.config.retry_config().into();
1851        let mocks = pack.mocks.as_deref();
1852        let tenant = self.config.tenant.as_str();
1853
1854        let ctx = FlowContext {
1855            tenant,
1856            pack_id: pack.metadata().pack_id.as_str(),
1857            flow_id,
1858            node_id: None,
1859            tool: None,
1860            action: None,
1861            session_id: None,
1862            provider_id: None,
1863            retry_config,
1864            attempt: 1,
1865            observer: None,
1866            mocks,
1867        };
1868
1869        let execution = engine.execute(ctx, input).await?;
1870        match execution.status {
1871            FlowStatus::Completed => Ok(execution.output),
1872            FlowStatus::Waiting(wait) => Ok(serde_json::json!({
1873                "status": "pending",
1874                "reason": wait.reason,
1875                "resume": wait.snapshot,
1876                "response": execution.output,
1877            })),
1878        }
1879    }
1880
1881    pub async fn invoke_component(
1882        &self,
1883        component_ref: &str,
1884        ctx: ComponentExecCtx,
1885        operation: &str,
1886        config_json: Option<String>,
1887        input_json: String,
1888    ) -> Result<Value> {
1889        let pack_component = self
1890            .components
1891            .get(component_ref)
1892            .with_context(|| format!("component '{component_ref}' not found in pack"))?;
1893        let engine = self.engine.clone();
1894        let config = Arc::clone(&self.config);
1895        let http_client = Arc::clone(&self.http_client);
1896        let mocks = self.mocks.clone();
1897        let session_store = self.session_store.clone();
1898        let state_store = self.state_store.clone();
1899        let secrets = Arc::clone(&self.secrets);
1900        let oauth_config = self.oauth_config.clone();
1901        let wasi_policy = Arc::clone(&self.wasi_policy);
1902        let pack_id = self.metadata().pack_id.clone();
1903        let allow_state_store = self.allows_state_store(component_ref);
1904        let component = pack_component.component.clone();
1905        let component_ref_owned = component_ref.to_string();
1906        let operation_owned = operation.to_string();
1907        let input_owned =
1908            Self::merge_component_config_into_input_json(config_json.as_deref(), &input_json)
1909                .context("merge component config into invocation payload")?;
1910        let ctx_owned = ctx;
1911
1912        run_on_wasi_thread("component.invoke", move || {
1913            let mut linker = Linker::new(&engine);
1914            register_all(&mut linker, allow_state_store)?;
1915            add_component_control_to_linker(&mut linker)?;
1916
1917            let host_state = HostState::new(
1918                pack_id.clone(),
1919                config,
1920                http_client,
1921                mocks,
1922                session_store,
1923                state_store,
1924                secrets,
1925                oauth_config,
1926                Some(ctx_owned.clone()),
1927                Some(component_ref_owned.clone()),
1928                false,
1929            )?;
1930            let store_state = ComponentState::new(host_state, wasi_policy)?;
1931            let mut store = wasmtime::Store::new(&engine, store_state);
1932
1933            let invoke_result = HostState::instantiate_component_result(
1934                &mut linker,
1935                &mut store,
1936                &component,
1937                &ctx_owned,
1938                &component_ref_owned,
1939                &operation_owned,
1940                &input_owned,
1941            )?;
1942            HostState::convert_invoke_result(invoke_result)
1943        })
1944    }
1945
1946    fn merge_component_config_into_input_json(
1947        config_json: Option<&str>,
1948        input_json: &str,
1949    ) -> Result<String> {
1950        let Some(config_json) = config_json else {
1951            return Ok(input_json.to_string());
1952        };
1953
1954        let config_value: Value =
1955            serde_json::from_str(config_json).context("parse component config JSON")?;
1956
1957        if let Ok(mut invocation) =
1958            serde_json::from_str::<greentic_types::InvocationEnvelope>(input_json)
1959        {
1960            let payload_value = serde_json::from_slice(&invocation.payload).unwrap_or_else(|_| {
1961                Value::String(String::from_utf8_lossy(&invocation.payload).into_owned())
1962            });
1963            invocation.payload = serde_json::to_vec(&serde_json::json!({
1964                "config": config_value,
1965                "input": payload_value,
1966            }))
1967            .context("serialize merged invocation payload")?;
1968            return serde_json::to_string(&invocation)
1969                .context("serialize merged invocation envelope");
1970        }
1971
1972        let input_value = serde_json::from_str(input_json)
1973            .unwrap_or_else(|_| Value::String(input_json.to_string()));
1974        serde_json::to_string(&serde_json::json!({
1975            "config": config_value,
1976            "input": input_value,
1977        }))
1978        .context("serialize merged component input")
1979    }
1980
1981    pub fn resolve_provider(
1982        &self,
1983        provider_id: Option<&str>,
1984        provider_type: Option<&str>,
1985    ) -> Result<ProviderBinding> {
1986        let registry = self.provider_registry()?;
1987        registry.resolve(provider_id, provider_type)
1988    }
1989
1990    pub async fn invoke_provider(
1991        &self,
1992        binding: &ProviderBinding,
1993        ctx: ComponentExecCtx,
1994        op: &str,
1995        input_json: Vec<u8>,
1996    ) -> Result<Value> {
1997        let component_ref_owned = binding.component_ref.clone();
1998        let pack_component = self.components.get(&component_ref_owned).with_context(|| {
1999            format!("provider component '{component_ref_owned}' not found in pack")
2000        })?;
2001        let component = pack_component.component.clone();
2002
2003        let engine = self.engine.clone();
2004        let config = Arc::clone(&self.config);
2005        let http_client = Arc::clone(&self.http_client);
2006        let mocks = self.mocks.clone();
2007        let session_store = self.session_store.clone();
2008        let state_store = self.state_store.clone();
2009        let secrets = Arc::clone(&self.secrets);
2010        let oauth_config = self.oauth_config.clone();
2011        let wasi_policy = Arc::clone(&self.wasi_policy);
2012        let pack_id = self.metadata().pack_id.clone();
2013        let allow_state_store = self.allows_state_store(&component_ref_owned);
2014        let input_owned = input_json;
2015        let op_owned = op.to_string();
2016        let ctx_owned = ctx;
2017        let world = binding.world.clone();
2018
2019        run_on_wasi_thread("provider.invoke", move || {
2020            let mut linker = Linker::new(&engine);
2021            register_all(&mut linker, allow_state_store)?;
2022            add_component_control_to_linker(&mut linker)?;
2023            let host_state = HostState::new(
2024                pack_id.clone(),
2025                config,
2026                http_client,
2027                mocks,
2028                session_store,
2029                state_store,
2030                secrets,
2031                oauth_config,
2032                Some(ctx_owned.clone()),
2033                Some(component_ref_owned.clone()),
2034                true,
2035            )?;
2036            let store_state = ComponentState::new(host_state, wasi_policy)?;
2037            let mut store = wasmtime::Store::new(&engine, store_state);
2038            let use_schema_core_schema = world.contains("provider-schema-core");
2039            let use_schema_core_path = world.contains("provider/schema-core");
2040            let result = if use_schema_core_schema {
2041                let pre_instance = linker.instantiate_pre(component.as_ref())?;
2042                let pre: SchemaSchemaCorePre<ComponentState> =
2043                    SchemaSchemaCorePre::new(pre_instance)?;
2044                let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2045                let provider = bindings.greentic_provider_schema_core_schema_core_api();
2046                provider.call_invoke(&mut store, &op_owned, &input_owned)?
2047            } else if use_schema_core_path {
2048                let pre_instance = linker.instantiate_pre(component.as_ref())?;
2049                let path_attempt = (|| -> Result<Vec<u8>> {
2050                    let pre: PathSchemaCorePre<ComponentState> =
2051                        PathSchemaCorePre::new(pre_instance)?;
2052                    let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2053                    let provider = bindings.greentic_provider_schema_core_api();
2054                    Ok(provider.call_invoke(&mut store, &op_owned, &input_owned)?)
2055                })();
2056                match path_attempt {
2057                    Ok(value) => value,
2058                    Err(path_err)
2059                        if path_err.to_string().contains("no exported instance named") =>
2060                    {
2061                        let pre_instance = linker.instantiate_pre(component.as_ref())?;
2062                        let pre: SchemaSchemaCorePre<ComponentState> =
2063                            SchemaSchemaCorePre::new(pre_instance)?;
2064                        let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2065                        let provider = bindings.greentic_provider_schema_core_schema_core_api();
2066                        provider.call_invoke(&mut store, &op_owned, &input_owned)?
2067                    }
2068                    Err(path_err) => return Err(path_err),
2069                }
2070            } else {
2071                let pre_instance = linker.instantiate_pre(component.as_ref())?;
2072                let pre: LegacySchemaCorePre<ComponentState> =
2073                    LegacySchemaCorePre::new(pre_instance)?;
2074                let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2075                let provider = bindings.greentic_provider_core_schema_core_api();
2076                provider.call_invoke(&mut store, &op_owned, &input_owned)?
2077            };
2078            deserialize_json_bytes(result)
2079        })
2080    }
2081
2082    pub(crate) fn provider_registry(&self) -> Result<ProviderRegistry> {
2083        if let Some(registry) = self.provider_registry.read().clone() {
2084            return Ok(registry);
2085        }
2086        let manifest = self
2087            .manifest
2088            .as_ref()
2089            .context("pack manifest required for provider resolution")?;
2090        let env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
2091        let registry = ProviderRegistry::new(
2092            manifest,
2093            self.state_store.clone(),
2094            &self.config.tenant,
2095            &env,
2096        )?;
2097        *self.provider_registry.write() = Some(registry.clone());
2098        Ok(registry)
2099    }
2100
2101    pub(crate) fn provider_registry_optional(&self) -> Result<Option<ProviderRegistry>> {
2102        if self.manifest.is_none() {
2103            return Ok(None);
2104        }
2105        Ok(Some(self.provider_registry()?))
2106    }
2107
2108    pub fn load_flow(&self, flow_id: &str) -> Result<Flow> {
2109        if let Some(cache) = &self.flows {
2110            return cache
2111                .flows
2112                .get(flow_id)
2113                .cloned()
2114                .ok_or_else(|| anyhow!("flow '{flow_id}' not found in pack"));
2115        }
2116        if let Some(manifest) = &self.manifest {
2117            let entry = manifest
2118                .flows
2119                .iter()
2120                .find(|f| f.id.as_str() == flow_id)
2121                .ok_or_else(|| anyhow!("flow '{flow_id}' not found in manifest"))?;
2122            return Ok(entry.flow.clone());
2123        }
2124        bail!("flow '{flow_id}' not available (pack exports disabled)")
2125    }
2126
2127    pub fn metadata(&self) -> &PackMetadata {
2128        &self.metadata
2129    }
2130
2131    /// Read an asset file from the pack's assets directory.
2132    ///
2133    /// Accepts paths like `assets/cards/card-a.json` or `cards/card-a.json`
2134    /// (the `assets/` prefix is stripped automatically).
2135    pub fn read_asset(&self, asset_path: &str) -> Result<Vec<u8>> {
2136        let normalized = asset_path
2137            .trim_start_matches("assets/")
2138            .trim_start_matches("/assets/");
2139        // Try assets tempdir first (extracted from archive).
2140        if let Some(tempdir) = &self.assets_tempdir {
2141            let full = tempdir.path().join("assets").join(normalized);
2142            if full.exists() {
2143                return std::fs::read(&full)
2144                    .with_context(|| format!("read asset {}", full.display()));
2145            }
2146        }
2147        // Try materialized directory.
2148        let full = self.path.join("assets").join(normalized);
2149        if full.exists() {
2150            return std::fs::read(&full).with_context(|| format!("read asset {}", full.display()));
2151        }
2152        bail!("asset not found: {}", asset_path)
2153    }
2154
2155    pub fn component_manifest(&self, component_ref: &str) -> Option<&ComponentManifest> {
2156        self.component_manifests.get(component_ref)
2157    }
2158
2159    pub fn describe_component_contract_v0_6(&self, component_ref: &str) -> Result<Option<Value>> {
2160        let pack_component = self
2161            .components
2162            .get(component_ref)
2163            .with_context(|| format!("component '{component_ref}' not found in pack"))?;
2164        let engine = self.engine.clone();
2165        let config = Arc::clone(&self.config);
2166        let http_client = Arc::clone(&self.http_client);
2167        let mocks = self.mocks.clone();
2168        let session_store = self.session_store.clone();
2169        let state_store = self.state_store.clone();
2170        let secrets = Arc::clone(&self.secrets);
2171        let oauth_config = self.oauth_config.clone();
2172        let wasi_policy = Arc::clone(&self.wasi_policy);
2173        let pack_id = self.metadata().pack_id.clone();
2174        let allow_state_store = self.allows_state_store(component_ref);
2175        let component = pack_component.component.clone();
2176        let component_ref_owned = component_ref.to_string();
2177
2178        run_on_wasi_thread("component.describe", move || {
2179            let mut linker = Linker::new(&engine);
2180            register_all(&mut linker, allow_state_store)?;
2181            add_component_control_to_linker(&mut linker)?;
2182
2183            let host_state = HostState::new(
2184                pack_id.clone(),
2185                config,
2186                http_client,
2187                mocks,
2188                session_store,
2189                state_store,
2190                secrets,
2191                oauth_config,
2192                None,
2193                Some(component_ref_owned),
2194                false,
2195            )?;
2196            let store_state = ComponentState::new(host_state, wasi_policy)?;
2197            let mut store = wasmtime::Store::new(&engine, store_state);
2198            let pre_instance = linker.instantiate_pre(&component)?;
2199            let pre = match component_api::v0_6_descriptor::ComponentV0V6V0Pre::new(pre_instance) {
2200                Ok(pre) => pre,
2201                Err(_) => return Ok(None),
2202            };
2203            let bytes = block_on(async {
2204                let bindings = pre.instantiate_async(&mut store).await?;
2205                let descriptor = bindings.greentic_component_component_descriptor();
2206                descriptor.call_describe(&mut store)
2207            })?;
2208
2209            if bytes.is_empty() {
2210                return Ok(Some(Value::Null));
2211            }
2212            if let Ok(value) = serde_cbor::from_slice::<Value>(&bytes) {
2213                return Ok(Some(value));
2214            }
2215            if let Ok(value) = serde_json::from_slice::<Value>(&bytes) {
2216                return Ok(Some(value));
2217            }
2218            if let Ok(text) = String::from_utf8(bytes) {
2219                if let Ok(value) = serde_json::from_str::<Value>(&text) {
2220                    return Ok(Some(value));
2221                }
2222                return Ok(Some(Value::String(text)));
2223            }
2224            Ok(Some(Value::Null))
2225        })
2226    }
2227
2228    pub fn load_schema_json(&self, schema_ref: &str) -> Result<Option<Value>> {
2229        let rel = normalize_schema_ref(schema_ref)?;
2230        if self.path.is_dir() {
2231            let candidate = self.path.join(&rel);
2232            if candidate.exists() {
2233                let bytes = std::fs::read(&candidate).with_context(|| {
2234                    format!("failed to read schema file {}", candidate.display())
2235                })?;
2236                let value = serde_json::from_slice::<Value>(&bytes)
2237                    .with_context(|| format!("invalid schema JSON in {}", candidate.display()))?;
2238                return Ok(Some(value));
2239            }
2240        }
2241
2242        if let Some(archive_path) = self
2243            .archive_path
2244            .as_ref()
2245            .or_else(|| path_is_gtpack(&self.path).then_some(&self.path))
2246        {
2247            let file = File::open(archive_path)
2248                .with_context(|| format!("failed to open {}", archive_path.display()))?;
2249            let mut archive = ZipArchive::new(file)
2250                .with_context(|| format!("failed to read pack {}", archive_path.display()))?;
2251            match archive.by_name(&rel) {
2252                Ok(mut entry) => {
2253                    let mut bytes = Vec::new();
2254                    entry.read_to_end(&mut bytes)?;
2255                    let value = serde_json::from_slice::<Value>(&bytes).with_context(|| {
2256                        format!("invalid schema JSON in {}:{}", archive_path.display(), rel)
2257                    })?;
2258                    Ok(Some(value))
2259                }
2260                Err(zip::result::ZipError::FileNotFound) => Ok(None),
2261                Err(err) => Err(anyhow!(err)).with_context(|| {
2262                    format!(
2263                        "failed to read schema `{}` from {}",
2264                        rel,
2265                        archive_path.display()
2266                    )
2267                }),
2268            }
2269        } else {
2270            Ok(None)
2271        }
2272    }
2273
2274    pub fn required_secrets(&self) -> &[greentic_types::SecretRequirement] {
2275        &self.metadata.secret_requirements
2276    }
2277
2278    pub fn missing_secrets(
2279        &self,
2280        tenant_ctx: &TypesTenantCtx,
2281    ) -> Vec<greentic_types::SecretRequirement> {
2282        let env = tenant_ctx.env.as_str().to_string();
2283        let tenant = tenant_ctx.tenant.as_str().to_string();
2284        let team = tenant_ctx.team.as_ref().map(|t| t.as_str().to_string());
2285        self.required_secrets()
2286            .iter()
2287            .filter(|req| {
2288                // scope must match current context if provided
2289                if let Some(scope) = &req.scope {
2290                    if scope.env != env {
2291                        return false;
2292                    }
2293                    if scope.tenant != tenant {
2294                        return false;
2295                    }
2296                    if let Some(ref team_req) = scope.team
2297                        && team.as_ref() != Some(team_req)
2298                    {
2299                        return false;
2300                    }
2301                }
2302                let ctx = self.config.tenant_ctx();
2303                read_secret_blocking(
2304                    &self.secrets,
2305                    &ctx,
2306                    &self.metadata.pack_id,
2307                    canonicalize_secret_key(req.key.as_str()).as_str(),
2308                )
2309                .is_err()
2310            })
2311            .cloned()
2312            .collect()
2313    }
2314
2315    pub fn for_component_test(
2316        components: Vec<(String, PathBuf)>,
2317        flows: HashMap<String, FlowIR>,
2318        pack_id: &str,
2319        config: Arc<HostConfig>,
2320    ) -> Result<Self> {
2321        let engine = Engine::default();
2322        let engine_profile =
2323            EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
2324        let cache = CacheManager::new(CacheConfig::default(), engine_profile);
2325        let mut component_map = HashMap::new();
2326        for (name, path) in components {
2327            if !path.exists() {
2328                bail!("component artifact missing: {}", path.display());
2329            }
2330            let wasm_bytes = std::fs::read(&path)?;
2331            let component =
2332                Arc::new(Component::from_binary(&engine, &wasm_bytes).map_err(|err| {
2333                    anyhow!("failed to compile component {}: {err}", path.display())
2334                })?);
2335            component_map.insert(
2336                name.clone(),
2337                PackComponent {
2338                    name,
2339                    version: "0.0.0".into(),
2340                    component,
2341                },
2342            );
2343        }
2344
2345        let mut flow_map = HashMap::new();
2346        let mut descriptors = Vec::new();
2347        for (id, ir) in flows {
2348            let flow_type = ir.flow_type.clone();
2349            let flow = flow_ir_to_flow(ir)?;
2350            flow_map.insert(id.clone(), flow);
2351            descriptors.push(FlowDescriptor {
2352                id: id.clone(),
2353                flow_type,
2354                pack_id: pack_id.to_string(),
2355                profile: "test".into(),
2356                version: "0.0.0".into(),
2357                description: None,
2358            });
2359        }
2360        let entry_flows = descriptors.iter().map(|flow| flow.id.clone()).collect();
2361        let metadata = PackMetadata {
2362            pack_id: pack_id.to_string(),
2363            version: "0.0.0".into(),
2364            entry_flows,
2365            secret_requirements: Vec::new(),
2366        };
2367        let flows_cache = PackFlows {
2368            descriptors: descriptors.clone(),
2369            flows: flow_map,
2370            metadata: metadata.clone(),
2371        };
2372
2373        Ok(Self {
2374            path: PathBuf::new(),
2375            archive_path: None,
2376            config,
2377            engine,
2378            metadata,
2379            manifest: None,
2380            legacy_manifest: None,
2381            component_manifests: HashMap::new(),
2382            mocks: None,
2383            flows: Some(flows_cache),
2384            components: component_map,
2385            http_client: Arc::clone(&HTTP_CLIENT),
2386            pre_cache: Mutex::new(HashMap::new()),
2387            session_store: None,
2388            state_store: None,
2389            wasi_policy: Arc::new(RunnerWasiPolicy::new()),
2390            assets_tempdir: None,
2391            provider_registry: RwLock::new(None),
2392            secrets: crate::secrets::default_manager()?,
2393            oauth_config: None,
2394            cache,
2395        })
2396    }
2397}
2398
2399fn normalize_schema_ref(schema_ref: &str) -> Result<String> {
2400    let candidate = schema_ref.trim();
2401    if candidate.is_empty() {
2402        bail!("schema ref cannot be empty");
2403    }
2404    let path = Path::new(candidate);
2405    if path.is_absolute() {
2406        bail!("schema ref must be relative: {}", schema_ref);
2407    }
2408    let mut normalized = PathBuf::new();
2409    for component in path.components() {
2410        match component {
2411            std::path::Component::Normal(part) => normalized.push(part),
2412            std::path::Component::CurDir => {}
2413            _ => bail!("schema ref must not contain traversal: {}", schema_ref),
2414        }
2415    }
2416    let normalized = normalized
2417        .to_str()
2418        .map(ToString::to_string)
2419        .ok_or_else(|| anyhow!("schema ref must be valid UTF-8"))?;
2420    if normalized.is_empty() {
2421        bail!("schema ref cannot normalize to empty path");
2422    }
2423    Ok(normalized)
2424}
2425
2426fn path_is_gtpack(path: &Path) -> bool {
2427    path.extension()
2428        .and_then(|ext| ext.to_str())
2429        .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
2430        .unwrap_or(false)
2431}
2432
2433fn is_missing_node_export(err: &wasmtime::Error, version: &str) -> bool {
2434    let message = err.to_string();
2435    message.contains("no exported instance named")
2436        && message.contains(&format!("greentic:component/node@{version}"))
2437}
2438
2439struct PackFlows {
2440    descriptors: Vec<FlowDescriptor>,
2441    flows: HashMap<String, Flow>,
2442    metadata: PackMetadata,
2443}
2444
2445const RUNTIME_FLOW_EXTENSION_IDS: [&str; 3] = [
2446    "greentic.pack.runtime_flow",
2447    "greentic.pack.flow_runtime",
2448    "greentic.pack.runtime_flows",
2449];
2450
2451#[derive(Debug, Deserialize)]
2452struct RuntimeFlowBundle {
2453    flows: Vec<RuntimeFlow>,
2454}
2455
2456#[derive(Debug, Deserialize)]
2457struct RuntimeFlow {
2458    id: String,
2459    #[serde(alias = "flow_type")]
2460    kind: FlowKind,
2461    #[serde(default)]
2462    schema_version: Option<String>,
2463    #[serde(default)]
2464    start: Option<String>,
2465    #[serde(default)]
2466    entrypoints: BTreeMap<String, Value>,
2467    nodes: BTreeMap<String, RuntimeNode>,
2468    #[serde(default)]
2469    metadata: Option<FlowMetadata>,
2470}
2471
2472#[derive(Debug, Deserialize)]
2473struct RuntimeNode {
2474    #[serde(alias = "component")]
2475    component_id: String,
2476    #[serde(default, alias = "operation")]
2477    operation_name: Option<String>,
2478    #[serde(default, alias = "payload", alias = "input")]
2479    operation_payload: Value,
2480    #[serde(default)]
2481    config: Value,
2482    #[serde(default)]
2483    routing: Option<Routing>,
2484    #[serde(default)]
2485    telemetry: Option<TelemetryHints>,
2486}
2487
2488fn deserialize_json_bytes(bytes: Vec<u8>) -> Result<Value> {
2489    if bytes.is_empty() {
2490        return Ok(Value::Null);
2491    }
2492    serde_json::from_slice(&bytes).or_else(|_| {
2493        String::from_utf8(bytes)
2494            .map(Value::String)
2495            .map_err(|err| anyhow!(err))
2496    })
2497}
2498
2499impl PackFlows {
2500    fn from_manifest(manifest: greentic_types::PackManifest) -> Self {
2501        if let Some(flows) = flows_from_runtime_extension(&manifest) {
2502            return flows;
2503        }
2504        let descriptors = manifest
2505            .flows
2506            .iter()
2507            .map(|entry| FlowDescriptor {
2508                id: entry.id.as_str().to_string(),
2509                flow_type: flow_kind_to_str(entry.kind).to_string(),
2510                pack_id: manifest.pack_id.as_str().to_string(),
2511                profile: manifest.pack_id.as_str().to_string(),
2512                version: manifest.version.to_string(),
2513                description: None,
2514            })
2515            .collect();
2516        let mut flows = HashMap::new();
2517        for entry in &manifest.flows {
2518            flows.insert(entry.id.as_str().to_string(), entry.flow.clone());
2519        }
2520        Self {
2521            metadata: PackMetadata::from_manifest(&manifest),
2522            descriptors,
2523            flows,
2524        }
2525    }
2526}
2527
2528fn flows_from_runtime_extension(manifest: &greentic_types::PackManifest) -> Option<PackFlows> {
2529    let extensions = manifest.extensions.as_ref()?;
2530    let extension = extensions.iter().find_map(|(key, ext)| {
2531        if RUNTIME_FLOW_EXTENSION_IDS
2532            .iter()
2533            .any(|candidate| candidate == key)
2534        {
2535            Some(ext)
2536        } else {
2537            None
2538        }
2539    })?;
2540    let runtime_flows = match decode_runtime_flow_extension(extension) {
2541        Some(flows) if !flows.is_empty() => flows,
2542        _ => return None,
2543    };
2544
2545    let descriptors = runtime_flows
2546        .iter()
2547        .map(|flow| FlowDescriptor {
2548            id: flow.id.as_str().to_string(),
2549            flow_type: flow_kind_to_str(flow.kind).to_string(),
2550            pack_id: manifest.pack_id.as_str().to_string(),
2551            profile: manifest.pack_id.as_str().to_string(),
2552            version: manifest.version.to_string(),
2553            description: None,
2554        })
2555        .collect::<Vec<_>>();
2556    let flows = runtime_flows
2557        .into_iter()
2558        .map(|flow| (flow.id.as_str().to_string(), flow))
2559        .collect();
2560
2561    Some(PackFlows {
2562        metadata: PackMetadata::from_manifest(manifest),
2563        descriptors,
2564        flows,
2565    })
2566}
2567
2568fn decode_runtime_flow_extension(extension: &ExtensionRef) -> Option<Vec<Flow>> {
2569    let value = match extension.inline.as_ref()? {
2570        ExtensionInline::Other(value) => value.clone(),
2571        _ => return None,
2572    };
2573
2574    if let Ok(bundle) = serde_json::from_value::<RuntimeFlowBundle>(value.clone()) {
2575        return Some(collect_runtime_flows(bundle.flows));
2576    }
2577
2578    if let Ok(flows) = serde_json::from_value::<Vec<RuntimeFlow>>(value.clone()) {
2579        return Some(collect_runtime_flows(flows));
2580    }
2581
2582    if let Ok(flows) = serde_json::from_value::<Vec<Flow>>(value) {
2583        return Some(flows);
2584    }
2585
2586    warn!(
2587        extension = %extension.kind,
2588        version = %extension.version,
2589        "runtime flow extension present but could not be decoded"
2590    );
2591    None
2592}
2593
2594fn collect_runtime_flows(flows: Vec<RuntimeFlow>) -> Vec<Flow> {
2595    flows
2596        .into_iter()
2597        .filter_map(|flow| match runtime_flow_to_flow(flow) {
2598            Ok(flow) => Some(flow),
2599            Err(err) => {
2600                warn!(error = %err, "failed to decode runtime flow");
2601                None
2602            }
2603        })
2604        .collect()
2605}
2606
2607fn runtime_flow_to_flow(runtime: RuntimeFlow) -> Result<Flow> {
2608    let flow_id = FlowId::from_str(&runtime.id)
2609        .with_context(|| format!("invalid flow id `{}`", runtime.id))?;
2610    let mut entrypoints = runtime.entrypoints;
2611    if entrypoints.is_empty()
2612        && let Some(start) = &runtime.start
2613    {
2614        entrypoints.insert("default".into(), Value::String(start.clone()));
2615    }
2616
2617    let mut nodes: IndexMap<NodeId, Node, FlowHasher> = IndexMap::default();
2618    for (id, node) in runtime.nodes {
2619        let node_id = NodeId::from_str(&id).with_context(|| format!("invalid node id `{id}`"))?;
2620        let component_id = ComponentId::from_str(&node.component_id)
2621            .with_context(|| format!("invalid component id `{}`", node.component_id))?;
2622        let operation_payload = if node.config.is_null() {
2623            node.operation_payload
2624        } else {
2625            serde_json::json!({
2626                "input": node.operation_payload,
2627                "config": node.config,
2628            })
2629        };
2630        let component = FlowComponentRef {
2631            id: component_id,
2632            pack_alias: None,
2633            operation: node.operation_name,
2634        };
2635        let routing = node.routing.unwrap_or(Routing::End);
2636        let telemetry = node.telemetry.unwrap_or_default();
2637        nodes.insert(
2638            node_id.clone(),
2639            Node {
2640                id: node_id,
2641                component,
2642                input: InputMapping {
2643                    mapping: operation_payload,
2644                },
2645                output: OutputMapping {
2646                    mapping: Value::Null,
2647                },
2648                err_map: None,
2649                routing,
2650                telemetry,
2651            },
2652        );
2653    }
2654
2655    Ok(Flow {
2656        schema_version: runtime.schema_version.unwrap_or_else(|| "1.0".to_string()),
2657        id: flow_id,
2658        kind: runtime.kind,
2659        entrypoints,
2660        nodes,
2661        metadata: runtime.metadata.unwrap_or_default(),
2662    })
2663}
2664
2665fn flow_kind_to_str(kind: greentic_types::FlowKind) -> &'static str {
2666    match kind {
2667        greentic_types::FlowKind::Messaging => "messaging",
2668        greentic_types::FlowKind::Event => "event",
2669        greentic_types::FlowKind::ComponentConfig => "component-config",
2670        greentic_types::FlowKind::Job => "job",
2671        greentic_types::FlowKind::Http => "http",
2672    }
2673}
2674
2675fn read_entry(archive: &mut ZipArchive<File>, name: &str) -> Result<Vec<u8>> {
2676    let mut file = archive
2677        .by_name(name)
2678        .with_context(|| format!("entry {name} missing from archive"))?;
2679    let mut buf = Vec::new();
2680    file.read_to_end(&mut buf)?;
2681    Ok(buf)
2682}
2683
2684fn normalize_flow_doc(mut doc: FlowDoc) -> FlowDoc {
2685    for node in doc.nodes.values_mut() {
2686        let Some((component_ref, payload)) = node
2687            .raw
2688            .iter()
2689            .next()
2690            .map(|(key, value)| (key.clone(), value.clone()))
2691        else {
2692            continue;
2693        };
2694        if component_ref.starts_with("emit.") {
2695            node.operation = Some(component_ref);
2696            node.payload = payload;
2697            node.raw.clear();
2698            continue;
2699        }
2700        let (target_component, operation, input, config) =
2701            infer_component_exec(&payload, &component_ref);
2702        let mut payload_obj = serde_json::Map::new();
2703        // component.exec is meta; ensure the payload carries the actual target component.
2704        payload_obj.insert("component".into(), Value::String(target_component));
2705        payload_obj.insert("operation".into(), Value::String(operation));
2706        payload_obj.insert("input".into(), input);
2707        if let Some(cfg) = config {
2708            payload_obj.insert("config".into(), cfg);
2709        }
2710        node.operation = Some("component.exec".to_string());
2711        node.payload = Value::Object(payload_obj);
2712        node.raw.clear();
2713    }
2714    doc
2715}
2716
2717fn infer_component_exec(
2718    payload: &Value,
2719    component_ref: &str,
2720) -> (String, String, Value, Option<Value>) {
2721    let default_op = if component_ref.starts_with("templating.") {
2722        "render"
2723    } else {
2724        "invoke"
2725    }
2726    .to_string();
2727
2728    if let Value::Object(map) = payload {
2729        let has_embedded_component =
2730            map.get("component").is_some() || map.get("component_ref").is_some();
2731        let op = map
2732            .get("op")
2733            .or_else(|| map.get("operation"))
2734            .and_then(Value::as_str)
2735            .map(|s| s.to_string())
2736            .unwrap_or_else(|| {
2737                if has_embedded_component {
2738                    component_ref.to_string()
2739                } else {
2740                    default_op.clone()
2741                }
2742            });
2743
2744        let mut input = map.clone();
2745        let config = input.remove("config");
2746        let canonical_input = if has_embedded_component {
2747            input.get("input").cloned()
2748        } else {
2749            None
2750        };
2751        let component = input
2752            .get("component")
2753            .or_else(|| input.get("component_ref"))
2754            .and_then(Value::as_str)
2755            .map(|s| s.to_string())
2756            .unwrap_or_else(|| component_ref.to_string());
2757        input.remove("component");
2758        input.remove("component_ref");
2759        input.remove("op");
2760        input.remove("operation");
2761        let input = canonical_input.unwrap_or(Value::Object(input));
2762        return (component, op, input, config);
2763    }
2764
2765    (component_ref.to_string(), default_op, payload.clone(), None)
2766}
2767
2768#[derive(Clone, Debug)]
2769struct ComponentSpec {
2770    id: String,
2771    version: String,
2772    legacy_path: Option<String>,
2773}
2774
2775#[derive(Clone, Debug)]
2776struct ComponentSourceInfo {
2777    digest: Option<String>,
2778    source: ComponentSourceRef,
2779    artifact: ComponentArtifactLocation,
2780    expected_wasm_sha256: Option<String>,
2781    skip_digest_verification: bool,
2782}
2783
2784#[derive(Clone, Debug)]
2785enum ComponentArtifactLocation {
2786    Inline { wasm_path: String },
2787    Remote,
2788}
2789
2790#[derive(Clone, Debug, Deserialize)]
2791struct PackLockV1 {
2792    schema_version: u32,
2793    components: Vec<PackLockComponent>,
2794}
2795
2796#[derive(Clone, Debug, Deserialize)]
2797struct PackLockComponent {
2798    name: String,
2799    #[serde(default, rename = "source_ref")]
2800    source_ref: Option<String>,
2801    #[serde(default, rename = "ref")]
2802    legacy_ref: Option<String>,
2803    #[serde(default)]
2804    component_id: Option<ComponentId>,
2805    #[serde(default)]
2806    bundled: Option<bool>,
2807    #[serde(default, rename = "bundled_path")]
2808    bundled_path: Option<String>,
2809    #[serde(default, rename = "path")]
2810    legacy_path: Option<String>,
2811    #[serde(default)]
2812    wasm_sha256: Option<String>,
2813    #[serde(default, rename = "sha256")]
2814    legacy_sha256: Option<String>,
2815    #[serde(default)]
2816    resolved_digest: Option<String>,
2817    #[serde(default)]
2818    digest: Option<String>,
2819}
2820
2821fn component_specs(
2822    manifest: Option<&greentic_types::PackManifest>,
2823    legacy_manifest: Option<&legacy_pack::PackManifest>,
2824    component_sources: Option<&ComponentSourcesV1>,
2825    pack_lock: Option<&PackLockV1>,
2826) -> Vec<ComponentSpec> {
2827    if let Some(manifest) = manifest {
2828        if !manifest.components.is_empty() {
2829            return manifest
2830                .components
2831                .iter()
2832                .map(|entry| ComponentSpec {
2833                    id: entry.id.as_str().to_string(),
2834                    version: entry.version.to_string(),
2835                    legacy_path: None,
2836                })
2837                .collect();
2838        }
2839        if let Some(lock) = pack_lock {
2840            let mut seen = HashSet::new();
2841            let mut specs = Vec::new();
2842            for entry in &lock.components {
2843                let id = entry
2844                    .component_id
2845                    .as_ref()
2846                    .map(|id| id.as_str())
2847                    .unwrap_or(entry.name.as_str());
2848                if seen.insert(id.to_string()) {
2849                    specs.push(ComponentSpec {
2850                        id: id.to_string(),
2851                        version: "0.0.0".to_string(),
2852                        legacy_path: None,
2853                    });
2854                }
2855            }
2856            return specs;
2857        }
2858        if let Some(sources) = component_sources {
2859            let mut seen = HashSet::new();
2860            let mut specs = Vec::new();
2861            for entry in &sources.components {
2862                let id = entry
2863                    .component_id
2864                    .as_ref()
2865                    .map(|id| id.as_str())
2866                    .unwrap_or(entry.name.as_str());
2867                if seen.insert(id.to_string()) {
2868                    specs.push(ComponentSpec {
2869                        id: id.to_string(),
2870                        version: "0.0.0".to_string(),
2871                        legacy_path: None,
2872                    });
2873                }
2874            }
2875            return specs;
2876        }
2877    }
2878    if let Some(legacy_manifest) = legacy_manifest {
2879        return legacy_manifest
2880            .components
2881            .iter()
2882            .map(|entry| ComponentSpec {
2883                id: entry.name.clone(),
2884                version: entry.version.to_string(),
2885                legacy_path: Some(entry.file_wasm.clone()),
2886            })
2887            .collect();
2888    }
2889    Vec::new()
2890}
2891
2892fn component_sources_table(
2893    sources: Option<&ComponentSourcesV1>,
2894) -> Result<Option<HashMap<String, ComponentSourceInfo>>> {
2895    let Some(sources) = sources else {
2896        return Ok(None);
2897    };
2898    let mut table = HashMap::new();
2899    for entry in &sources.components {
2900        let artifact = match &entry.artifact {
2901            ArtifactLocationV1::Inline { wasm_path, .. } => ComponentArtifactLocation::Inline {
2902                wasm_path: wasm_path.clone(),
2903            },
2904            ArtifactLocationV1::Remote => ComponentArtifactLocation::Remote,
2905        };
2906        let info = ComponentSourceInfo {
2907            digest: Some(entry.resolved.digest.clone()),
2908            source: entry.source.clone(),
2909            artifact,
2910            expected_wasm_sha256: None,
2911            skip_digest_verification: false,
2912        };
2913        if let Some(component_id) = entry.component_id.as_ref() {
2914            table.insert(component_id.as_str().to_string(), info.clone());
2915        }
2916        table.insert(entry.name.clone(), info);
2917    }
2918    Ok(Some(table))
2919}
2920
2921fn load_pack_lock(path: &Path) -> Result<Option<PackLockV1>> {
2922    let lock_path = if path.is_dir() {
2923        let candidate = path.join("pack.lock");
2924        if candidate.exists() {
2925            Some(candidate)
2926        } else {
2927            let candidate = path.join("pack.lock.json");
2928            candidate.exists().then_some(candidate)
2929        }
2930    } else {
2931        None
2932    };
2933    let Some(lock_path) = lock_path else {
2934        return Ok(None);
2935    };
2936    let raw = std::fs::read_to_string(&lock_path)
2937        .with_context(|| format!("failed to read {}", lock_path.display()))?;
2938    let lock: PackLockV1 = serde_json::from_str(&raw).context("failed to parse pack.lock")?;
2939    if lock.schema_version != 1 {
2940        bail!("pack.lock schema_version must be 1");
2941    }
2942    Ok(Some(lock))
2943}
2944
2945fn find_pack_lock_roots(
2946    pack_path: &Path,
2947    is_dir: bool,
2948    archive_hint: Option<&Path>,
2949) -> Vec<PathBuf> {
2950    if is_dir {
2951        return vec![pack_path.to_path_buf()];
2952    }
2953    let mut roots = Vec::new();
2954    if let Some(archive_path) = archive_hint {
2955        if let Some(parent) = archive_path.parent() {
2956            roots.push(parent.to_path_buf());
2957            if let Some(grandparent) = parent.parent() {
2958                roots.push(grandparent.to_path_buf());
2959            }
2960        }
2961    } else if let Some(parent) = pack_path.parent() {
2962        roots.push(parent.to_path_buf());
2963        if let Some(grandparent) = parent.parent() {
2964            roots.push(grandparent.to_path_buf());
2965        }
2966    }
2967    roots
2968}
2969
2970fn normalize_sha256(digest: &str) -> Result<String> {
2971    let trimmed = digest.trim();
2972    if trimmed.is_empty() {
2973        bail!("sha256 digest cannot be empty");
2974    }
2975    if let Some(stripped) = trimmed.strip_prefix("sha256:") {
2976        if stripped.is_empty() {
2977            bail!("sha256 digest must include hex bytes after sha256:");
2978        }
2979        return Ok(trimmed.to_string());
2980    }
2981    if trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
2982        return Ok(format!("sha256:{trimmed}"));
2983    }
2984    bail!("sha256 digest must be hex or sha256:<hex>");
2985}
2986
2987fn component_sources_table_from_pack_lock(
2988    lock: &PackLockV1,
2989    allow_missing_hash: bool,
2990) -> Result<HashMap<String, ComponentSourceInfo>> {
2991    let mut table = HashMap::new();
2992    let mut names = HashSet::new();
2993    for entry in &lock.components {
2994        if !names.insert(entry.name.clone()) {
2995            bail!(
2996                "pack.lock contains duplicate component name `{}`",
2997                entry.name
2998            );
2999        }
3000        let source_ref = match (&entry.source_ref, &entry.legacy_ref) {
3001            (Some(primary), Some(legacy)) => {
3002                if primary != legacy {
3003                    bail!(
3004                        "pack.lock component {} has conflicting refs: {} vs {}",
3005                        entry.name,
3006                        primary,
3007                        legacy
3008                    );
3009                }
3010                primary.as_str()
3011            }
3012            (Some(primary), None) => primary.as_str(),
3013            (None, Some(legacy)) => legacy.as_str(),
3014            (None, None) => {
3015                bail!("pack.lock component {} missing source_ref", entry.name);
3016            }
3017        };
3018        let source: ComponentSourceRef = source_ref
3019            .parse()
3020            .with_context(|| format!("invalid component ref `{}`", source_ref))?;
3021        let bundled_path = match (&entry.bundled_path, &entry.legacy_path) {
3022            (Some(primary), Some(legacy)) => {
3023                if primary != legacy {
3024                    bail!(
3025                        "pack.lock component {} has conflicting bundled paths: {} vs {}",
3026                        entry.name,
3027                        primary,
3028                        legacy
3029                    );
3030                }
3031                Some(primary.clone())
3032            }
3033            (Some(primary), None) => Some(primary.clone()),
3034            (None, Some(legacy)) => Some(legacy.clone()),
3035            (None, None) => None,
3036        };
3037        let bundled = entry.bundled.unwrap_or(false) || bundled_path.is_some();
3038        let (artifact, digest, expected_wasm_sha256, skip_digest_verification) = if bundled {
3039            let wasm_path = bundled_path.ok_or_else(|| {
3040                anyhow!(
3041                    "pack.lock component {} marked bundled but bundled_path is missing",
3042                    entry.name
3043                )
3044            })?;
3045            let expected_raw = match (&entry.wasm_sha256, &entry.legacy_sha256) {
3046                (Some(primary), Some(legacy)) => {
3047                    if primary != legacy {
3048                        bail!(
3049                            "pack.lock component {} has conflicting wasm_sha256 values: {} vs {}",
3050                            entry.name,
3051                            primary,
3052                            legacy
3053                        );
3054                    }
3055                    Some(primary.as_str())
3056                }
3057                (Some(primary), None) => Some(primary.as_str()),
3058                (None, Some(legacy)) => Some(legacy.as_str()),
3059                (None, None) => None,
3060            };
3061            let expected = match expected_raw {
3062                Some(value) => Some(normalize_sha256(value)?),
3063                None => None,
3064            };
3065            if expected.is_none() && !allow_missing_hash {
3066                bail!(
3067                    "pack.lock component {} missing wasm_sha256 for bundled component",
3068                    entry.name
3069                );
3070            }
3071            (
3072                ComponentArtifactLocation::Inline { wasm_path },
3073                expected.clone(),
3074                expected,
3075                allow_missing_hash && expected_raw.is_none(),
3076            )
3077        } else {
3078            if source.is_tag() {
3079                bail!(
3080                    "component {} uses tag ref {} but is not bundled; rebuild the pack",
3081                    entry.name,
3082                    source
3083                );
3084            }
3085            let expected = entry
3086                .resolved_digest
3087                .as_deref()
3088                .or(entry.digest.as_deref())
3089                .ok_or_else(|| {
3090                    anyhow!(
3091                        "pack.lock component {} missing resolved_digest for remote component",
3092                        entry.name
3093                    )
3094                })?;
3095            (
3096                ComponentArtifactLocation::Remote,
3097                Some(normalize_digest(expected)),
3098                None,
3099                false,
3100            )
3101        };
3102        let info = ComponentSourceInfo {
3103            digest,
3104            source,
3105            artifact,
3106            expected_wasm_sha256,
3107            skip_digest_verification,
3108        };
3109        if let Some(component_id) = entry.component_id.as_ref() {
3110            let key = component_id.as_str().to_string();
3111            if table.contains_key(&key) {
3112                bail!(
3113                    "pack.lock contains duplicate component id `{}`",
3114                    component_id.as_str()
3115                );
3116            }
3117            table.insert(key, info.clone());
3118        }
3119        if entry.name
3120            != entry
3121                .component_id
3122                .as_ref()
3123                .map(|id| id.as_str())
3124                .unwrap_or("")
3125        {
3126            table.insert(entry.name.clone(), info);
3127        }
3128    }
3129    Ok(table)
3130}
3131
3132fn component_path_for_spec(root: &Path, spec: &ComponentSpec) -> PathBuf {
3133    if let Some(path) = &spec.legacy_path {
3134        return root.join(path);
3135    }
3136    root.join("components").join(format!("{}.wasm", spec.id))
3137}
3138
3139fn normalize_digest(digest: &str) -> String {
3140    if digest.starts_with("sha256:") || digest.starts_with("blake3:") {
3141        digest.to_string()
3142    } else {
3143        format!("sha256:{digest}")
3144    }
3145}
3146
3147fn compute_digest_for(bytes: &[u8], digest: &str) -> Result<String> {
3148    if digest.starts_with("blake3:") {
3149        let hash = blake3::hash(bytes);
3150        return Ok(format!("blake3:{}", hash.to_hex()));
3151    }
3152    let mut hasher = sha2::Sha256::new();
3153    hasher.update(bytes);
3154    Ok(format!("sha256:{}", to_hex(&hasher.finalize())))
3155}
3156
3157fn compute_sha256_digest_for(bytes: &[u8]) -> String {
3158    let mut hasher = sha2::Sha256::new();
3159    hasher.update(bytes);
3160    format!("sha256:{}", to_hex(&hasher.finalize()))
3161}
3162
3163fn build_artifact_key(cache: &CacheManager, digest: Option<&str>, bytes: &[u8]) -> ArtifactKey {
3164    let wasm_digest = digest
3165        .map(normalize_digest)
3166        .unwrap_or_else(|| compute_sha256_digest_for(bytes));
3167    ArtifactKey::new(cache.engine_profile_id().to_string(), wasm_digest)
3168}
3169
3170async fn compile_component_with_cache(
3171    cache: &CacheManager,
3172    engine: &Engine,
3173    digest: Option<&str>,
3174    bytes: Vec<u8>,
3175) -> Result<Arc<Component>> {
3176    let key = build_artifact_key(cache, digest, &bytes);
3177    cache.get_component(engine, &key, || Ok(bytes)).await
3178}
3179
3180fn verify_component_digest(component_id: &str, expected: &str, bytes: &[u8]) -> Result<()> {
3181    let normalized_expected = normalize_digest(expected);
3182    let actual = compute_digest_for(bytes, &normalized_expected)?;
3183    if normalize_digest(&actual) != normalized_expected {
3184        bail!(
3185            "component {component_id} digest mismatch: expected {normalized_expected}, got {actual}"
3186        );
3187    }
3188    Ok(())
3189}
3190
3191fn verify_wasm_sha256(component_id: &str, expected: &str, bytes: &[u8]) -> Result<()> {
3192    let normalized_expected = normalize_sha256(expected)?;
3193    let actual = compute_sha256_digest_for(bytes);
3194    if actual != normalized_expected {
3195        bail!(
3196            "component {component_id} bundled digest mismatch: expected {normalized_expected}, got {actual}"
3197        );
3198    }
3199    Ok(())
3200}
3201
3202fn to_hex(digest: &[u8]) -> String {
3203    digest.iter().map(|byte| format!("{byte:02x}")).collect()
3204}
3205
3206#[cfg(test)]
3207mod pack_lock_tests {
3208    use super::*;
3209    use tempfile::TempDir;
3210
3211    #[test]
3212    fn pack_lock_tag_ref_requires_bundle() {
3213        let lock = PackLockV1 {
3214            schema_version: 1,
3215            components: vec![PackLockComponent {
3216                name: "templates".to_string(),
3217                source_ref: Some("oci://registry.test/templates:latest".to_string()),
3218                legacy_ref: None,
3219                component_id: None,
3220                bundled: Some(false),
3221                bundled_path: None,
3222                legacy_path: None,
3223                wasm_sha256: None,
3224                legacy_sha256: None,
3225                resolved_digest: None,
3226                digest: None,
3227            }],
3228        };
3229        let err = component_sources_table_from_pack_lock(&lock, false).unwrap_err();
3230        assert!(
3231            err.to_string().contains("tag ref") && err.to_string().contains("rebuild the pack"),
3232            "unexpected error: {err}"
3233        );
3234    }
3235
3236    #[test]
3237    fn bundled_hash_mismatch_errors() {
3238        let rt = tokio::runtime::Runtime::new().expect("runtime");
3239        let temp = TempDir::new().expect("temp dir");
3240        let engine = Engine::default();
3241        let engine_profile =
3242            EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
3243        let cache_config = CacheConfig {
3244            root: temp.path().join("cache"),
3245            ..CacheConfig::default()
3246        };
3247        let cache = CacheManager::new(cache_config, engine_profile);
3248        let wasm_path = temp.path().join("component.wasm");
3249        let fixture_wasm = Path::new(env!("CARGO_MANIFEST_DIR"))
3250            .join("../../tests/fixtures/packs/secrets_store_smoke/components/echo_secret.wasm");
3251        let bytes = std::fs::read(&fixture_wasm).expect("read fixture wasm");
3252        std::fs::write(&wasm_path, &bytes).expect("write temp wasm");
3253
3254        let spec = ComponentSpec {
3255            id: "qa.process".to_string(),
3256            version: "0.0.0".to_string(),
3257            legacy_path: None,
3258        };
3259        let mut missing = HashSet::new();
3260        missing.insert(spec.id.clone());
3261
3262        let mut sources = HashMap::new();
3263        sources.insert(
3264            spec.id.clone(),
3265            ComponentSourceInfo {
3266                digest: Some("sha256:deadbeef".to_string()),
3267                source: ComponentSourceRef::Oci("registry.test/qa.process@sha256:deadbeef".into()),
3268                artifact: ComponentArtifactLocation::Inline {
3269                    wasm_path: "component.wasm".to_string(),
3270                },
3271                expected_wasm_sha256: Some("sha256:deadbeef".to_string()),
3272                skip_digest_verification: false,
3273            },
3274        );
3275
3276        let mut loaded = HashMap::new();
3277        let result = rt.block_on(load_components_from_sources(
3278            &cache,
3279            &engine,
3280            &sources,
3281            &ComponentResolution::default(),
3282            &[spec],
3283            &mut missing,
3284            &mut loaded,
3285            Some(temp.path()),
3286            None,
3287        ));
3288        let err = result.unwrap_err();
3289        assert!(
3290            err.to_string().contains("bundled digest mismatch"),
3291            "unexpected error: {err}"
3292        );
3293    }
3294}
3295
3296#[cfg(test)]
3297mod pack_resolution_prop_tests {
3298    use super::*;
3299    use greentic_types::{ArtifactLocationV1, ComponentSourceEntryV1, ResolvedComponentV1};
3300    use proptest::prelude::*;
3301    use proptest::test_runner::{Config as ProptestConfig, RngAlgorithm, TestRng, TestRunner};
3302    use std::collections::BTreeSet;
3303    use std::path::Path;
3304    use std::str::FromStr;
3305
3306    #[derive(Clone, Debug)]
3307    enum ResolveRequest {
3308        ById(String),
3309        ByName(String),
3310    }
3311
3312    #[derive(Clone, Debug, PartialEq, Eq)]
3313    struct ResolvedComponent {
3314        key: String,
3315        source: String,
3316        artifact: String,
3317        digest: Option<String>,
3318        expected_wasm_sha256: Option<String>,
3319        skip_digest_verification: bool,
3320    }
3321
3322    #[derive(Clone, Debug, PartialEq, Eq)]
3323    struct ResolveError {
3324        code: String,
3325        message: String,
3326        context_key: String,
3327    }
3328
3329    #[derive(Clone, Debug)]
3330    struct Scenario {
3331        pack_lock: Option<PackLockV1>,
3332        component_sources: Option<ComponentSourcesV1>,
3333        request: ResolveRequest,
3334        expected_sha256: Option<String>,
3335        bytes: Vec<u8>,
3336    }
3337
3338    fn resolve_component_test(
3339        sources: Option<&ComponentSourcesV1>,
3340        lock: Option<&PackLockV1>,
3341        request: &ResolveRequest,
3342    ) -> Result<ResolvedComponent, ResolveError> {
3343        let table = if let Some(lock) = lock {
3344            component_sources_table_from_pack_lock(lock, false).map_err(|err| ResolveError {
3345                code: classify_pack_lock_error(err.to_string().as_str()).to_string(),
3346                message: err.to_string(),
3347                context_key: request_key(request).to_string(),
3348            })?
3349        } else {
3350            let sources = component_sources_table(sources).map_err(|err| ResolveError {
3351                code: "component_sources_error".to_string(),
3352                message: err.to_string(),
3353                context_key: request_key(request).to_string(),
3354            })?;
3355            sources.ok_or_else(|| ResolveError {
3356                code: "missing_component_sources".to_string(),
3357                message: "component sources not provided".to_string(),
3358                context_key: request_key(request).to_string(),
3359            })?
3360        };
3361
3362        let key = request_key(request);
3363        let source = table.get(key).ok_or_else(|| ResolveError {
3364            code: "component_not_found".to_string(),
3365            message: format!("component {key} not found"),
3366            context_key: key.to_string(),
3367        })?;
3368
3369        Ok(ResolvedComponent {
3370            key: key.to_string(),
3371            source: source.source.to_string(),
3372            artifact: match source.artifact {
3373                ComponentArtifactLocation::Inline { .. } => "inline".to_string(),
3374                ComponentArtifactLocation::Remote => "remote".to_string(),
3375            },
3376            digest: source.digest.clone(),
3377            expected_wasm_sha256: source.expected_wasm_sha256.clone(),
3378            skip_digest_verification: source.skip_digest_verification,
3379        })
3380    }
3381
3382    fn request_key(request: &ResolveRequest) -> &str {
3383        match request {
3384            ResolveRequest::ById(value) => value.as_str(),
3385            ResolveRequest::ByName(value) => value.as_str(),
3386        }
3387    }
3388
3389    fn classify_pack_lock_error(message: &str) -> &'static str {
3390        if message.contains("duplicate component name") {
3391            "duplicate_name"
3392        } else if message.contains("duplicate component id") {
3393            "duplicate_id"
3394        } else if message.contains("conflicting refs") {
3395            "conflicting_ref"
3396        } else if message.contains("conflicting bundled paths") {
3397            "conflicting_bundled_path"
3398        } else if message.contains("conflicting wasm_sha256") {
3399            "conflicting_wasm_sha256"
3400        } else if message.contains("missing source_ref") {
3401            "missing_source_ref"
3402        } else if message.contains("marked bundled but bundled_path is missing") {
3403            "missing_bundled_path"
3404        } else if message.contains("missing wasm_sha256") {
3405            "missing_wasm_sha256"
3406        } else if message.contains("tag ref") && message.contains("not bundled") {
3407            "tag_ref_requires_bundle"
3408        } else if message.contains("missing resolved_digest") {
3409            "missing_resolved_digest"
3410        } else if message.contains("invalid component ref") {
3411            "invalid_component_ref"
3412        } else if message.contains("sha256 digest") {
3413            "invalid_sha256"
3414        } else {
3415            "unknown_error"
3416        }
3417    }
3418
3419    fn known_error_codes() -> BTreeSet<&'static str> {
3420        [
3421            "component_sources_error",
3422            "missing_component_sources",
3423            "component_not_found",
3424            "duplicate_name",
3425            "duplicate_id",
3426            "conflicting_ref",
3427            "conflicting_bundled_path",
3428            "conflicting_wasm_sha256",
3429            "missing_source_ref",
3430            "missing_bundled_path",
3431            "missing_wasm_sha256",
3432            "tag_ref_requires_bundle",
3433            "missing_resolved_digest",
3434            "invalid_component_ref",
3435            "invalid_sha256",
3436            "unknown_error",
3437        ]
3438        .into_iter()
3439        .collect()
3440    }
3441
3442    fn proptest_config() -> ProptestConfig {
3443        let cases = std::env::var("PROPTEST_CASES")
3444            .ok()
3445            .and_then(|value| value.parse::<u32>().ok())
3446            .unwrap_or(128);
3447        ProptestConfig {
3448            cases,
3449            failure_persistence: None,
3450            ..ProptestConfig::default()
3451        }
3452    }
3453
3454    fn proptest_seed() -> Option<[u8; 32]> {
3455        let seed = std::env::var("PROPTEST_SEED")
3456            .ok()
3457            .and_then(|value| value.parse::<u64>().ok())?;
3458        let mut bytes = [0u8; 32];
3459        bytes[..8].copy_from_slice(&seed.to_le_bytes());
3460        Some(bytes)
3461    }
3462
3463    fn run_cases(strategy: impl Strategy<Value = Scenario>, cases: u32, seed: Option<[u8; 32]>) {
3464        let config = ProptestConfig {
3465            cases,
3466            failure_persistence: None,
3467            ..ProptestConfig::default()
3468        };
3469        let mut runner = match seed {
3470            Some(bytes) => {
3471                TestRunner::new_with_rng(config, TestRng::from_seed(RngAlgorithm::ChaCha, &bytes))
3472            }
3473            None => TestRunner::new(config),
3474        };
3475        runner
3476            .run(&strategy, |scenario| {
3477                run_scenario(&scenario);
3478                Ok(())
3479            })
3480            .unwrap();
3481    }
3482
3483    fn run_scenario(scenario: &Scenario) {
3484        let known_codes = known_error_codes();
3485        let first = resolve_component_test(
3486            scenario.component_sources.as_ref(),
3487            scenario.pack_lock.as_ref(),
3488            &scenario.request,
3489        );
3490        let second = resolve_component_test(
3491            scenario.component_sources.as_ref(),
3492            scenario.pack_lock.as_ref(),
3493            &scenario.request,
3494        );
3495        assert_eq!(normalize_result(&first), normalize_result(&second));
3496
3497        if let Some(lock) = scenario.pack_lock.as_ref() {
3498            let lock_only = resolve_component_test(None, Some(lock), &scenario.request);
3499            assert_eq!(normalize_result(&first), normalize_result(&lock_only));
3500        }
3501
3502        if let Err(err) = first.as_ref() {
3503            assert!(
3504                known_codes.contains(err.code.as_str()),
3505                "unexpected error code {}: {}",
3506                err.code,
3507                err.message
3508            );
3509        }
3510
3511        if let Some(expected) = scenario.expected_sha256.as_deref() {
3512            let expected_ok =
3513                verify_wasm_sha256("test.component", expected, &scenario.bytes).is_ok();
3514            let actual = compute_sha256_digest_for(&scenario.bytes);
3515            if actual == normalize_sha256(expected).unwrap_or_default() {
3516                assert!(expected_ok, "expected sha256 match to succeed");
3517            } else {
3518                assert!(!expected_ok, "expected sha256 mismatch to fail");
3519            }
3520        }
3521    }
3522
3523    fn normalize_result(
3524        result: &Result<ResolvedComponent, ResolveError>,
3525    ) -> Result<ResolvedComponent, ResolveError> {
3526        match result {
3527            Ok(value) => Ok(value.clone()),
3528            Err(err) => Err(err.clone()),
3529        }
3530    }
3531
3532    fn scenario_strategy() -> impl Strategy<Value = Scenario> {
3533        let name = any::<u8>().prop_map(|n| format!("component{n}.core"));
3534        let alt_name = any::<u8>().prop_map(|n| format!("component_alt{n}.core"));
3535        let tag_ref = any::<bool>();
3536        let bundled = any::<bool>();
3537        let include_sha = any::<bool>();
3538        let include_component_id = any::<bool>();
3539        let request_by_id = any::<bool>();
3540        let use_lock = any::<bool>();
3541        let use_sources = any::<bool>();
3542        let bytes = prop::collection::vec(any::<u8>(), 1..64);
3543
3544        (
3545            name,
3546            alt_name,
3547            tag_ref,
3548            bundled,
3549            include_sha,
3550            include_component_id,
3551            request_by_id,
3552            use_lock,
3553            use_sources,
3554            bytes,
3555        )
3556            .prop_map(
3557                |(
3558                    name,
3559                    alt_name,
3560                    tag_ref,
3561                    bundled,
3562                    include_sha,
3563                    include_component_id,
3564                    request_by_id,
3565                    use_lock,
3566                    use_sources,
3567                    bytes,
3568                )| {
3569                    let component_id_str = if include_component_id {
3570                        alt_name.clone()
3571                    } else {
3572                        name.clone()
3573                    };
3574                    let component_id = ComponentId::from_str(&component_id_str).ok();
3575                    let source_ref = if tag_ref {
3576                        format!("oci://registry.test/{name}:v1")
3577                    } else {
3578                        format!(
3579                            "oci://registry.test/{name}@sha256:{}",
3580                            hex::encode([0x11u8; 32])
3581                        )
3582                    };
3583                    let expected_sha256 = if bundled && include_sha {
3584                        Some(compute_sha256_digest_for(&bytes))
3585                    } else {
3586                        None
3587                    };
3588
3589                    let lock_component = PackLockComponent {
3590                        name: name.clone(),
3591                        source_ref: Some(source_ref),
3592                        legacy_ref: None,
3593                        component_id,
3594                        bundled: Some(bundled),
3595                        bundled_path: if bundled {
3596                            Some(format!("components/{name}.wasm"))
3597                        } else {
3598                            None
3599                        },
3600                        legacy_path: None,
3601                        wasm_sha256: expected_sha256.clone(),
3602                        legacy_sha256: None,
3603                        resolved_digest: if bundled {
3604                            None
3605                        } else {
3606                            Some("sha256:deadbeef".to_string())
3607                        },
3608                        digest: None,
3609                    };
3610
3611                    let pack_lock = if use_lock {
3612                        Some(PackLockV1 {
3613                            schema_version: 1,
3614                            components: vec![lock_component],
3615                        })
3616                    } else {
3617                        None
3618                    };
3619
3620                    let component_sources = if use_sources {
3621                        Some(ComponentSourcesV1::new(vec![ComponentSourceEntryV1 {
3622                            name: name.clone(),
3623                            component_id: ComponentId::from_str(&name).ok(),
3624                            source: ComponentSourceRef::from_str(
3625                                "oci://registry.test/component@sha256:deadbeef",
3626                            )
3627                            .expect("component ref"),
3628                            resolved: ResolvedComponentV1 {
3629                                digest: "sha256:deadbeef".to_string(),
3630                                signature: None,
3631                                signed_by: None,
3632                            },
3633                            artifact: if bundled {
3634                                ArtifactLocationV1::Inline {
3635                                    wasm_path: format!("components/{name}.wasm"),
3636                                    manifest_path: None,
3637                                }
3638                            } else {
3639                                ArtifactLocationV1::Remote
3640                            },
3641                            licensing_hint: None,
3642                            metering_hint: None,
3643                        }]))
3644                    } else {
3645                        None
3646                    };
3647
3648                    let request = if request_by_id {
3649                        ResolveRequest::ById(component_id_str.clone())
3650                    } else {
3651                        ResolveRequest::ByName(name.clone())
3652                    };
3653
3654                    Scenario {
3655                        pack_lock,
3656                        component_sources,
3657                        request,
3658                        expected_sha256,
3659                        bytes,
3660                    }
3661                },
3662            )
3663    }
3664
3665    #[test]
3666    fn pack_resolution_proptest() {
3667        let seed = proptest_seed();
3668        run_cases(scenario_strategy(), proptest_config().cases, seed);
3669    }
3670
3671    #[test]
3672    fn pack_resolution_regression_seeds() {
3673        let seeds_path =
3674            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/proptest-seeds.txt");
3675        let raw = std::fs::read_to_string(&seeds_path).expect("read proptest seeds");
3676        for line in raw.lines() {
3677            let line = line.trim();
3678            if line.is_empty() || line.starts_with('#') {
3679                continue;
3680            }
3681            let seed = line.parse::<u64>().expect("seed must be an integer");
3682            let mut bytes = [0u8; 32];
3683            bytes[..8].copy_from_slice(&seed.to_le_bytes());
3684            run_cases(scenario_strategy(), 1, Some(bytes));
3685        }
3686    }
3687}
3688
3689fn locate_pack_assets(
3690    materialized_root: Option<&Path>,
3691    archive_hint: Option<&Path>,
3692) -> Result<(Option<PathBuf>, Option<TempDir>)> {
3693    if let Some(root) = materialized_root {
3694        let assets = root.join("assets");
3695        if assets.is_dir() {
3696            return Ok((Some(assets), None));
3697        }
3698    }
3699    if let Some(path) = archive_hint
3700        && let Some((tempdir, assets)) = extract_assets_from_archive(path)?
3701    {
3702        return Ok((Some(assets), Some(tempdir)));
3703    }
3704    Ok((None, None))
3705}
3706
3707fn extract_assets_from_archive(path: &Path) -> Result<Option<(TempDir, PathBuf)>> {
3708    let file =
3709        File::open(path).with_context(|| format!("failed to open pack {}", path.display()))?;
3710    let mut archive =
3711        ZipArchive::new(file).with_context(|| format!("failed to read pack {}", path.display()))?;
3712    let temp = TempDir::new().context("failed to create temporary assets directory")?;
3713    let mut found = false;
3714    for idx in 0..archive.len() {
3715        let mut entry = archive.by_index(idx)?;
3716        let name = entry.name();
3717        if !name.starts_with("assets/") {
3718            continue;
3719        }
3720        let dest = temp.path().join(name);
3721        if name.ends_with('/') {
3722            std::fs::create_dir_all(&dest)?;
3723            found = true;
3724            continue;
3725        }
3726        if let Some(parent) = dest.parent() {
3727            std::fs::create_dir_all(parent)?;
3728        }
3729        let mut outfile = std::fs::File::create(&dest)?;
3730        std::io::copy(&mut entry, &mut outfile)?;
3731        found = true;
3732    }
3733    if found {
3734        let assets_path = temp.path().join("assets");
3735        Ok(Some((temp, assets_path)))
3736    } else {
3737        Ok(None)
3738    }
3739}
3740
3741fn dist_options_from(component_resolution: &ComponentResolution) -> DistOptions {
3742    let mut opts = DistOptions {
3743        allow_tags: true,
3744        ..DistOptions::default()
3745    };
3746    if let Some(cache_dir) = component_resolution.dist_cache_dir.clone() {
3747        opts.cache_dir = cache_dir;
3748    }
3749    if component_resolution.dist_offline {
3750        opts.offline = true;
3751    }
3752    opts
3753}
3754
3755#[allow(clippy::too_many_arguments)]
3756async fn load_components_from_sources(
3757    cache: &CacheManager,
3758    engine: &Engine,
3759    component_sources: &HashMap<String, ComponentSourceInfo>,
3760    component_resolution: &ComponentResolution,
3761    specs: &[ComponentSpec],
3762    missing: &mut HashSet<String>,
3763    into: &mut HashMap<String, PackComponent>,
3764    materialized_root: Option<&Path>,
3765    archive_hint: Option<&Path>,
3766) -> Result<()> {
3767    let mut archive = if let Some(path) = archive_hint {
3768        Some(
3769            ZipArchive::new(File::open(path)?)
3770                .with_context(|| format!("{} is not a valid gtpack", path.display()))?,
3771        )
3772    } else {
3773        None
3774    };
3775    let mut dist_client: Option<DistClient> = None;
3776
3777    for spec in specs {
3778        if !missing.contains(&spec.id) {
3779            continue;
3780        }
3781        let Some(source) = component_sources.get(&spec.id) else {
3782            continue;
3783        };
3784
3785        let bytes = match &source.artifact {
3786            ComponentArtifactLocation::Inline { wasm_path } => {
3787                if let Some(root) = materialized_root {
3788                    let path = root.join(wasm_path);
3789                    if path.exists() {
3790                        std::fs::read(&path).with_context(|| {
3791                            format!(
3792                                "failed to read inline component {} from {}",
3793                                spec.id,
3794                                path.display()
3795                            )
3796                        })?
3797                    } else if archive.is_none() {
3798                        bail!("inline component {} missing at {}", spec.id, path.display());
3799                    } else {
3800                        read_entry(
3801                            archive.as_mut().expect("archive present when needed"),
3802                            wasm_path,
3803                        )
3804                        .with_context(|| {
3805                            format!(
3806                                "inline component {} missing at {} in pack archive",
3807                                spec.id, wasm_path
3808                            )
3809                        })?
3810                    }
3811                } else if let Some(archive) = archive.as_mut() {
3812                    read_entry(archive, wasm_path).with_context(|| {
3813                        format!(
3814                            "inline component {} missing at {} in pack archive",
3815                            spec.id, wasm_path
3816                        )
3817                    })?
3818                } else {
3819                    bail!(
3820                        "inline component {} missing and no pack source available",
3821                        spec.id
3822                    );
3823                }
3824            }
3825            ComponentArtifactLocation::Remote => {
3826                if source.source.is_tag() {
3827                    bail!(
3828                        "component {} uses tag ref {} but is not bundled; rebuild the pack",
3829                        spec.id,
3830                        source.source
3831                    );
3832                }
3833                let client = dist_client.get_or_insert_with(|| {
3834                    DistClient::new(dist_options_from(component_resolution))
3835                });
3836                let reference = source.source.to_string();
3837                fault::maybe_fail_asset(&reference)
3838                    .await
3839                    .with_context(|| format!("fault injection blocked asset {reference}"))?;
3840                let digest = source.digest.as_deref().ok_or_else(|| {
3841                    anyhow!(
3842                        "component {} missing expected digest for remote component",
3843                        spec.id
3844                    )
3845                })?;
3846                let cache_path = if let Ok(cache_path) = client.fetch_digest(digest).await {
3847                    cache_path
3848                } else if component_resolution.dist_offline {
3849                    client
3850                        .fetch_digest(digest)
3851                        .await
3852                        .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?
3853                } else {
3854                    let source = client
3855                        .parse_source(&reference)
3856                        .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?;
3857                    let descriptor = client
3858                        .resolve(source, ResolvePolicy)
3859                        .await
3860                        .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?;
3861                    let resolved = client
3862                        .fetch(&descriptor, CachePolicy)
3863                        .await
3864                        .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?;
3865                    let expected = normalize_digest(digest);
3866                    let actual = normalize_digest(&resolved.digest);
3867                    if expected != actual {
3868                        bail!(
3869                            "component {} digest mismatch after fetch: expected {}, got {}",
3870                            spec.id,
3871                            expected,
3872                            actual
3873                        );
3874                    }
3875                    resolved.cache_path.ok_or_else(|| {
3876                        anyhow!(
3877                            "component {} resolved from {} but cache path is missing",
3878                            spec.id,
3879                            reference
3880                        )
3881                    })?
3882                };
3883                std::fs::read(&cache_path).with_context(|| {
3884                    format!(
3885                        "failed to read cached component {} from {}",
3886                        spec.id,
3887                        cache_path.display()
3888                    )
3889                })?
3890            }
3891        };
3892
3893        if let Some(expected) = source.expected_wasm_sha256.as_deref() {
3894            verify_wasm_sha256(&spec.id, expected, &bytes)?;
3895        } else if source.skip_digest_verification {
3896            let actual = compute_sha256_digest_for(&bytes);
3897            warn!(
3898                component_id = %spec.id,
3899                digest = %actual,
3900                "bundled component missing wasm_sha256; allowing due to flag"
3901            );
3902        } else {
3903            let expected = source.digest.as_deref().ok_or_else(|| {
3904                anyhow!(
3905                    "component {} missing expected digest for verification",
3906                    spec.id
3907                )
3908            })?;
3909            verify_component_digest(&spec.id, expected, &bytes)?;
3910        }
3911        let component =
3912            compile_component_with_cache(cache, engine, source.digest.as_deref(), bytes)
3913                .await
3914                .with_context(|| format!("failed to compile component {}", spec.id))?;
3915        into.insert(
3916            spec.id.clone(),
3917            PackComponent {
3918                name: spec.id.clone(),
3919                version: spec.version.clone(),
3920                component,
3921            },
3922        );
3923        missing.remove(&spec.id);
3924    }
3925
3926    Ok(())
3927}
3928
3929fn dist_error_for_component(err: DistError, component_id: &str, reference: &str) -> anyhow::Error {
3930    match err {
3931        DistError::NotFound { reference: missing } => anyhow!(
3932            "remote component {} is not cached for {}. Run `greentic-dist pull --lock <pack.lock>` or `greentic-dist pull {}`",
3933            component_id,
3934            missing,
3935            reference
3936        ),
3937        DistError::Offline { reference: blocked } => anyhow!(
3938            "offline mode blocked fetching component {} from {}; run `greentic-dist pull --lock <pack.lock>` or `greentic-dist pull {}`",
3939            component_id,
3940            blocked,
3941            reference
3942        ),
3943        DistError::Unauthorized { target } => anyhow!(
3944            "component {} requires authenticated source {}; run `greentic-dist pull --lock <pack.lock>` or `greentic-dist pull {}`",
3945            component_id,
3946            target,
3947            reference
3948        ),
3949        other => anyhow!(
3950            "failed to resolve component {} from {}: {}",
3951            component_id,
3952            reference,
3953            other
3954        ),
3955    }
3956}
3957
3958async fn load_components_from_overrides(
3959    cache: &CacheManager,
3960    engine: &Engine,
3961    overrides: &HashMap<String, PathBuf>,
3962    specs: &[ComponentSpec],
3963    missing: &mut HashSet<String>,
3964    into: &mut HashMap<String, PackComponent>,
3965) -> Result<()> {
3966    for spec in specs {
3967        if !missing.contains(&spec.id) {
3968            continue;
3969        }
3970        let Some(path) = overrides.get(&spec.id) else {
3971            continue;
3972        };
3973        let bytes = std::fs::read(path)
3974            .with_context(|| format!("failed to read override component {}", path.display()))?;
3975        let component = compile_component_with_cache(cache, engine, None, bytes)
3976            .await
3977            .with_context(|| {
3978                format!(
3979                    "failed to compile component {} from override {}",
3980                    spec.id,
3981                    path.display()
3982                )
3983            })?;
3984        into.insert(
3985            spec.id.clone(),
3986            PackComponent {
3987                name: spec.id.clone(),
3988                version: spec.version.clone(),
3989                component,
3990            },
3991        );
3992        missing.remove(&spec.id);
3993    }
3994    Ok(())
3995}
3996
3997async fn load_components_from_dir(
3998    cache: &CacheManager,
3999    engine: &Engine,
4000    root: &Path,
4001    specs: &[ComponentSpec],
4002    missing: &mut HashSet<String>,
4003    into: &mut HashMap<String, PackComponent>,
4004) -> Result<()> {
4005    for spec in specs {
4006        if !missing.contains(&spec.id) {
4007            continue;
4008        }
4009        let path = component_path_for_spec(root, spec);
4010        if !path.exists() {
4011            tracing::debug!(component = %spec.id, path = %path.display(), "materialized component missing; will try other sources");
4012            continue;
4013        }
4014        let bytes = std::fs::read(&path)
4015            .with_context(|| format!("failed to read component {}", path.display()))?;
4016        let component = compile_component_with_cache(cache, engine, None, bytes)
4017            .await
4018            .with_context(|| {
4019                format!(
4020                    "failed to compile component {} from {}",
4021                    spec.id,
4022                    path.display()
4023                )
4024            })?;
4025        into.insert(
4026            spec.id.clone(),
4027            PackComponent {
4028                name: spec.id.clone(),
4029                version: spec.version.clone(),
4030                component,
4031            },
4032        );
4033        missing.remove(&spec.id);
4034    }
4035    Ok(())
4036}
4037
4038async fn load_components_from_archive(
4039    cache: &CacheManager,
4040    engine: &Engine,
4041    path: &Path,
4042    specs: &[ComponentSpec],
4043    missing: &mut HashSet<String>,
4044    into: &mut HashMap<String, PackComponent>,
4045) -> Result<()> {
4046    let mut archive = ZipArchive::new(File::open(path)?)
4047        .with_context(|| format!("{} is not a valid gtpack", path.display()))?;
4048    for spec in specs {
4049        if !missing.contains(&spec.id) {
4050            continue;
4051        }
4052        let file_name = spec
4053            .legacy_path
4054            .clone()
4055            .unwrap_or_else(|| format!("components/{}.wasm", spec.id));
4056        let bytes = match read_entry(&mut archive, &file_name) {
4057            Ok(bytes) => bytes,
4058            Err(err) => {
4059                warn!(component = %spec.id, pack = %path.display(), error = %err, "component entry missing in pack archive");
4060                continue;
4061            }
4062        };
4063        let component = compile_component_with_cache(cache, engine, None, bytes)
4064            .await
4065            .with_context(|| format!("failed to compile component {}", spec.id))?;
4066        into.insert(
4067            spec.id.clone(),
4068            PackComponent {
4069                name: spec.id.clone(),
4070                version: spec.version.clone(),
4071                component,
4072            },
4073        );
4074        missing.remove(&spec.id);
4075    }
4076    Ok(())
4077}
4078
4079#[cfg(test)]
4080mod tests {
4081    use super::*;
4082    use greentic_flow::model::{FlowDoc, NodeDoc};
4083    use indexmap::IndexMap;
4084    use serde_json::json;
4085
4086    #[test]
4087    fn normalizes_raw_component_to_component_exec() {
4088        let mut nodes = IndexMap::new();
4089        let mut raw = IndexMap::new();
4090        raw.insert(
4091            "templating.handlebars".into(),
4092            json!({ "template": "Hi {{name}}" }),
4093        );
4094        nodes.insert(
4095            "start".into(),
4096            NodeDoc {
4097                raw,
4098                routing: json!([{"out": true}]),
4099                ..Default::default()
4100            },
4101        );
4102        let doc = FlowDoc {
4103            id: "welcome".into(),
4104            title: None,
4105            description: None,
4106            flow_type: "messaging".into(),
4107            start: Some("start".into()),
4108            parameters: json!({}),
4109            tags: Vec::new(),
4110            schema_version: None,
4111            entrypoints: IndexMap::new(),
4112            meta: None,
4113            nodes,
4114        };
4115
4116        let normalized = normalize_flow_doc(doc);
4117        let node = normalized.nodes.get("start").expect("node exists");
4118        assert_eq!(node.operation.as_deref(), Some("component.exec"));
4119        assert!(node.raw.is_empty());
4120        let payload = node.payload.as_object().expect("payload object");
4121        assert_eq!(
4122            payload.get("component"),
4123            Some(&Value::String("templating.handlebars".into()))
4124        );
4125        assert_eq!(
4126            payload.get("operation"),
4127            Some(&Value::String("render".into()))
4128        );
4129        let input = payload.get("input").unwrap();
4130        assert_eq!(input, &json!({ "template": "Hi {{name}}" }));
4131    }
4132
4133    #[test]
4134    fn normalizes_canonical_operation_node_to_component_exec_with_config() {
4135        let mut nodes = IndexMap::new();
4136        let mut raw = IndexMap::new();
4137        raw.insert(
4138            "handle_message".into(),
4139            json!({
4140                "component": "oci://ghcr.io/greenticai/component/component-llm-openai:stable",
4141                "config": {
4142                    "provider": "ollama",
4143                    "base_url": "http://127.0.0.1:11434/v1",
4144                    "default_model": "llama3.2"
4145                },
4146                "input": {
4147                    "messages": [{
4148                        "role": "user",
4149                        "content": "Say hello from Ollama."
4150                    }]
4151                }
4152            }),
4153        );
4154        nodes.insert(
4155            "llm".into(),
4156            NodeDoc {
4157                raw,
4158                routing: json!([{"out": true}]),
4159                ..Default::default()
4160            },
4161        );
4162        let doc = FlowDoc {
4163            id: "ollama-repro".into(),
4164            title: None,
4165            description: None,
4166            flow_type: "messaging".into(),
4167            start: Some("llm".into()),
4168            parameters: json!({}),
4169            tags: Vec::new(),
4170            schema_version: None,
4171            entrypoints: IndexMap::new(),
4172            meta: None,
4173            nodes,
4174        };
4175
4176        let normalized = normalize_flow_doc(doc);
4177        let node = normalized.nodes.get("llm").expect("node exists");
4178        assert_eq!(node.operation.as_deref(), Some("component.exec"));
4179        assert!(node.raw.is_empty());
4180        let payload = node.payload.as_object().expect("payload object");
4181        assert_eq!(
4182            payload.get("component"),
4183            Some(&Value::String(
4184                "oci://ghcr.io/greenticai/component/component-llm-openai:stable".into()
4185            ))
4186        );
4187        assert_eq!(
4188            payload.get("operation"),
4189            Some(&Value::String("handle_message".into()))
4190        );
4191        assert_eq!(
4192            payload.get("config"),
4193            Some(&json!({
4194                "provider": "ollama",
4195                "base_url": "http://127.0.0.1:11434/v1",
4196                "default_model": "llama3.2"
4197            }))
4198        );
4199        assert_eq!(
4200            payload.get("input"),
4201            Some(&json!({
4202                "messages": [{
4203                    "role": "user",
4204                    "content": "Say hello from Ollama."
4205                }]
4206            }))
4207        );
4208    }
4209}
4210
4211#[derive(Clone, Debug, Default, Serialize, Deserialize)]
4212pub struct PackMetadata {
4213    pub pack_id: String,
4214    pub version: String,
4215    #[serde(default)]
4216    pub entry_flows: Vec<String>,
4217    #[serde(default)]
4218    pub secret_requirements: Vec<greentic_types::SecretRequirement>,
4219}
4220
4221impl PackMetadata {
4222    fn from_wasm(bytes: &[u8]) -> Option<Self> {
4223        let parser = Parser::new(0);
4224        for payload in parser.parse_all(bytes) {
4225            let payload = payload.ok()?;
4226            match payload {
4227                Payload::CustomSection(section) => {
4228                    if section.name() == "greentic.manifest"
4229                        && let Ok(meta) = Self::from_bytes(section.data())
4230                    {
4231                        return Some(meta);
4232                    }
4233                }
4234                Payload::DataSection(reader) => {
4235                    for segment in reader.into_iter().flatten() {
4236                        if let Ok(meta) = Self::from_bytes(segment.data) {
4237                            return Some(meta);
4238                        }
4239                    }
4240                }
4241                _ => {}
4242            }
4243        }
4244        None
4245    }
4246
4247    fn from_bytes(bytes: &[u8]) -> Result<Self, serde_cbor::Error> {
4248        #[derive(Deserialize)]
4249        struct RawManifest {
4250            pack_id: String,
4251            version: String,
4252            #[serde(default)]
4253            entry_flows: Vec<String>,
4254            #[serde(default)]
4255            flows: Vec<RawFlow>,
4256            #[serde(default)]
4257            secret_requirements: Vec<greentic_types::SecretRequirement>,
4258        }
4259
4260        #[derive(Deserialize)]
4261        struct RawFlow {
4262            id: String,
4263        }
4264
4265        let manifest: RawManifest = serde_cbor::from_slice(bytes)?;
4266        let mut entry_flows = if manifest.entry_flows.is_empty() {
4267            manifest.flows.iter().map(|f| f.id.clone()).collect()
4268        } else {
4269            manifest.entry_flows.clone()
4270        };
4271        entry_flows.retain(|id| !id.is_empty());
4272        Ok(Self {
4273            pack_id: manifest.pack_id,
4274            version: manifest.version,
4275            entry_flows,
4276            secret_requirements: manifest.secret_requirements,
4277        })
4278    }
4279
4280    pub fn fallback(path: &Path) -> Self {
4281        let pack_id = path
4282            .file_stem()
4283            .map(|s| s.to_string_lossy().into_owned())
4284            .unwrap_or_else(|| "unknown-pack".to_string());
4285        Self {
4286            pack_id,
4287            version: "0.0.0".to_string(),
4288            entry_flows: Vec::new(),
4289            secret_requirements: Vec::new(),
4290        }
4291    }
4292
4293    pub fn from_manifest(manifest: &greentic_types::PackManifest) -> Self {
4294        let entry_flows = manifest
4295            .flows
4296            .iter()
4297            .map(|flow| flow.id.as_str().to_string())
4298            .collect::<Vec<_>>();
4299        Self {
4300            pack_id: manifest.pack_id.as_str().to_string(),
4301            version: manifest.version.to_string(),
4302            entry_flows,
4303            secret_requirements: manifest.secret_requirements.clone(),
4304        }
4305    }
4306}