Skip to main content

lenso_runtime_codec/
lib.rs

1//! Shared artifact and generated Capability codec seams for Execution Adapters.
2
3use std::{
4    any::Any,
5    collections::BTreeMap,
6    env, fs,
7    io::Write as _,
8    path::{Path, PathBuf},
9    rc::Rc,
10    sync::Arc,
11};
12
13use lenso_app_plan::{
14    CapabilityCardinality, ExecutionClassId, PluginInstancePlan, ResolvedAppPlan,
15};
16use lenso_kernel::{
17    InvocationContext, NativeRequestEndpoint, NativeStream, NativeStreamEndpoint, NativeStreamItem,
18    NativeStreamSession, PluginDependencies, PluginDependencyHandle, PluginStreamDependencyHandle,
19    PreparedBinding, PreparedNativeApp, PreparedNativePlugin, PreparedStreamBinding,
20    RuntimeFailure, StreamCapability, StreamEvent,
21};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use sha2::{Digest, Sha256};
25
26/// Immutable, content-addressed files owned by one Plugin Instance Generation.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct InstanceResources {
29    digest: String,
30    total_size: u64,
31    files: BTreeMap<String, Arc<[u8]>>,
32}
33
34impl Default for InstanceResources {
35    fn default() -> Self {
36        Self::from_files([]).expect("the empty resource snapshot is valid")
37    }
38}
39
40impl InstanceResources {
41    /// Builds one deterministic snapshot from normalized relative paths and owned bytes.
42    pub fn from_files(
43        files: impl IntoIterator<Item = (String, Vec<u8>)>,
44    ) -> Result<Self, RuntimeFailure> {
45        let mut indexed = BTreeMap::<String, Arc<[u8]>>::new();
46        let mut total_size = 0_u64;
47        for (path, bytes) in files {
48            validate_resource_path(&path)?;
49            total_size = total_size
50                .checked_add(
51                    u64::try_from(bytes.len())
52                        .map_err(|_| invalid_resources("resource file is too large"))?,
53                )
54                .ok_or_else(|| invalid_resources("resource snapshot size overflow"))?;
55            if indexed.insert(path.clone(), Arc::from(bytes)).is_some() {
56                return Err(invalid_resources(format!(
57                    "duplicate Plugin resource path `{path}`"
58                )));
59            }
60        }
61        let mut hasher = Sha256::new();
62        hasher.update(b"lenso.instance-resources@1\0");
63        for (path, bytes) in &indexed {
64            hasher.update(
65                u64::try_from(path.len())
66                    .expect("path length fits u64")
67                    .to_be_bytes(),
68            );
69            hasher.update(path.as_bytes());
70            hasher.update(
71                u64::try_from(bytes.len())
72                    .expect("content length fits u64")
73                    .to_be_bytes(),
74            );
75            hasher.update(bytes.as_ref());
76        }
77        Ok(Self {
78            digest: format!("sha256:{}", hex::encode(hasher.finalize())),
79            total_size,
80            files: indexed,
81        })
82    }
83
84    /// Returns the deterministic identity of every path and byte in this snapshot.
85    pub fn digest(&self) -> &str {
86        &self.digest
87    }
88
89    /// Returns the number of snapshotted files.
90    pub fn file_count(&self) -> usize {
91        self.files.len()
92    }
93
94    /// Returns the aggregate byte size.
95    pub const fn total_size(&self) -> u64 {
96        self.total_size
97    }
98
99    /// Lists normalized paths in deterministic order.
100    pub fn paths(&self) -> impl Iterator<Item = &str> {
101        self.files.keys().map(String::as_str)
102    }
103
104    /// Reads one immutable resource without consulting the live filesystem.
105    pub fn read(&self, path: &str) -> Result<&[u8], RuntimeFailure> {
106        validate_resource_path(path)?;
107        self.files
108            .get(path)
109            .map(AsRef::as_ref)
110            .ok_or_else(|| invalid_resources(format!("Plugin resource `{path}` was not found")))
111    }
112
113    /// Reads one immutable UTF-8 resource.
114    pub fn read_text(&self, path: &str) -> Result<&str, RuntimeFailure> {
115        std::str::from_utf8(self.read(path)?)
116            .map_err(|_| invalid_resources(format!("Plugin resource `{path}` is not UTF-8")))
117    }
118}
119
120/// Immutable Instance-to-resource-snapshot mapping injected by the Generation Supervisor.
121#[derive(Clone, Debug, Default)]
122pub struct InstanceResourceCatalog {
123    snapshots: BTreeMap<String, InstanceResources>,
124    empty: InstanceResources,
125}
126
127impl InstanceResourceCatalog {
128    /// Creates an empty catalog.
129    pub fn new() -> Self {
130        Self::default()
131    }
132
133    /// Adds one exact Instance snapshot and rejects duplicate authority.
134    pub fn with_resources(
135        mut self,
136        instance_key: impl Into<String>,
137        resources: InstanceResources,
138    ) -> Result<Self, RuntimeFailure> {
139        let instance_key = instance_key.into();
140        if self
141            .snapshots
142            .insert(instance_key.clone(), resources)
143            .is_some()
144        {
145            return Err(invalid_resources(format!(
146                "duplicate resource authority for Instance `{instance_key}`"
147            )));
148        }
149        Ok(self)
150    }
151
152    /// Returns the selected snapshot or an immutable empty snapshot.
153    pub fn for_instance(&self, instance_key: &str) -> &InstanceResources {
154        self.snapshots.get(instance_key).unwrap_or(&self.empty)
155    }
156
157    /// Iterates selected Instance snapshots in deterministic order.
158    pub fn iter(&self) -> impl Iterator<Item = (&str, &InstanceResources)> {
159        self.snapshots
160            .iter()
161            .map(|(instance, resources)| (instance.as_str(), resources))
162    }
163}
164
165/// Digest-verified, read-only execution input selected before Adapter preparation.
166#[derive(Debug)]
167struct ArtifactBacking {
168    path: PathBuf,
169    _directory: tempfile::TempDir,
170}
171
172/// One immutable content snapshot captured during Artifact admission.
173#[derive(Clone)]
174pub struct ArtifactHandle {
175    source_path: PathBuf,
176    backing: Arc<ArtifactBacking>,
177    digest: String,
178    size: u64,
179}
180
181impl std::fmt::Debug for ArtifactHandle {
182    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        formatter
184            .debug_struct("ArtifactHandle")
185            .field("source_path", &self.source_path)
186            .field("path", &self.backing.path)
187            .field("digest", &self.digest)
188            .field("size", &self.size)
189            .finish_non_exhaustive()
190    }
191}
192
193impl PartialEq for ArtifactHandle {
194    fn eq(&self, other: &Self) -> bool {
195        self.source_path == other.source_path
196            && self.digest == other.digest
197            && self.size == other.size
198    }
199}
200
201impl Eq for ArtifactHandle {}
202
203impl ArtifactHandle {
204    /// Verifies one regular file and snapshots it in a process-private directory selected by the
205    /// Host's system-temporary policy, independently of the source path. Strict isolation or
206    /// path-based execution on a no-exec temporary filesystem should use
207    /// [`Self::open_with_staging_root`] with a Host-owned executable root.
208    pub fn open(
209        path: impl Into<PathBuf>,
210        expected_digest: &str,
211        expected_size: u64,
212    ) -> Result<Self, RuntimeFailure> {
213        Self::open_inner(path.into(), expected_digest, expected_size, None)
214    }
215
216    /// Verifies one Artifact and places its private stable copy under an explicit Host-owned root.
217    /// Process-capable Hosts should select a root on a filesystem that permits execution.
218    pub fn open_with_staging_root(
219        path: impl Into<PathBuf>,
220        expected_digest: &str,
221        expected_size: u64,
222        staging_root: impl AsRef<Path>,
223    ) -> Result<Self, RuntimeFailure> {
224        Self::open_inner(
225            path.into(),
226            expected_digest,
227            expected_size,
228            Some(staging_root.as_ref()),
229        )
230    }
231
232    fn open_inner(
233        path: PathBuf,
234        expected_digest: &str,
235        expected_size: u64,
236        staging_root: Option<&Path>,
237    ) -> Result<Self, RuntimeFailure> {
238        validate_digest(expected_digest)?;
239        let path = absolute_path(path)?;
240        let metadata =
241            fs::symlink_metadata(&path).map_err(|error| invalid_artifact(&path, error))?;
242        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
243            return Err(RuntimeFailure::InvalidResolvedPlan {
244                detail: format!("Artifact `{}` is not a regular file", path.display()),
245            });
246        }
247        if metadata.len() != expected_size {
248            return Err(RuntimeFailure::InvalidResolvedPlan {
249                detail: format!(
250                    "Artifact `{}` size mismatch: expected {expected_size}, got {}",
251                    path.display(),
252                    metadata.len()
253                ),
254            });
255        }
256        let mut source = fs::File::open(&path).map_err(|error| invalid_artifact(&path, error))?;
257        let opened_metadata = source
258            .metadata()
259            .map_err(|error| invalid_artifact(&path, error))?;
260        if !opened_metadata.is_file() || opened_metadata.len() != expected_size {
261            return Err(RuntimeFailure::InvalidResolvedPlan {
262                detail: format!("Artifact `{}` changed during admission", path.display()),
263            });
264        }
265        let (backing, actual_digest, actual_size) =
266            materialize_stable_artifact(&path, &mut source, &opened_metadata, staging_root)?;
267        if actual_size != expected_size {
268            return Err(RuntimeFailure::InvalidResolvedPlan {
269                detail: format!("Artifact `{}` changed during admission", path.display()),
270            });
271        }
272        if actual_digest != expected_digest {
273            return Err(RuntimeFailure::InvalidResolvedPlan {
274                detail: format!("Artifact `{}` digest mismatch", path.display()),
275            });
276        }
277        Ok(Self {
278            source_path: path,
279            backing: Arc::new(backing),
280            digest: actual_digest,
281            size: opened_metadata.len(),
282        })
283    }
284
285    /// Returns the private stable copy containing the bytes admitted by this Handle.
286    /// It is never serialized into a Plan.
287    pub fn path(&self) -> &Path {
288        &self.backing.path
289    }
290
291    /// Returns the original machine-local selection path for relative resource policy.
292    pub fn source_path(&self) -> &Path {
293        &self.source_path
294    }
295
296    /// Returns the verified content identity.
297    pub fn digest(&self) -> &str {
298        &self.digest
299    }
300
301    /// Returns the verified byte size.
302    pub const fn size(&self) -> u64 {
303        self.size
304    }
305
306    /// Returns the exact bytes captured during admission.
307    pub fn read_verified(&self) -> Result<Vec<u8>, RuntimeFailure> {
308        let bytes = fs::read(&self.backing.path)
309            .map_err(|error| invalid_artifact(&self.backing.path, error))?;
310        let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
311        let digest = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
312        if size != self.size || digest != self.digest {
313            return Err(RuntimeFailure::InvalidResolvedPlan {
314                detail: format!(
315                    "stable Artifact `{}` changed after admission",
316                    self.backing.path.display()
317                ),
318            });
319        }
320        Ok(bytes)
321    }
322}
323
324fn absolute_path(path: PathBuf) -> Result<PathBuf, RuntimeFailure> {
325    if path.is_absolute() {
326        return Ok(path);
327    }
328    env::current_dir()
329        .map(|current| current.join(path))
330        .map_err(|error| invalid_artifact(Path::new("."), error))
331}
332
333fn materialize_stable_artifact(
334    source_path: &Path,
335    source: &mut fs::File,
336    source_metadata: &fs::Metadata,
337    staging_root: Option<&Path>,
338) -> Result<(ArtifactBacking, String, u64), RuntimeFailure> {
339    let directory = stable_artifact_directory(source_path, staging_root)?;
340    let file_name = source_path
341        .file_name()
342        .ok_or_else(|| RuntimeFailure::InvalidResolvedPlan {
343            detail: format!("Artifact `{}` has no filename", source_path.display()),
344        })?;
345    let stable_path = directory.path().join(file_name);
346    let mut stable = fs::OpenOptions::new()
347        .create_new(true)
348        .write(true)
349        .open(&stable_path)
350        .map_err(|error| invalid_artifact(source_path, error))?;
351    let mut hasher = Sha256::new();
352    let mut size = 0_u64;
353    let mut buffer = vec![0_u8; 64 * 1024];
354    loop {
355        let read = std::io::Read::read(source, &mut buffer)
356            .map_err(|error| invalid_artifact(source_path, error))?;
357        if read == 0 {
358            break;
359        }
360        size = size
361            .checked_add(u64::try_from(read).expect("buffer length fits u64"))
362            .ok_or_else(|| RuntimeFailure::InvalidResolvedPlan {
363                detail: format!("Artifact `{}` size overflow", source_path.display()),
364            })?;
365        hasher.update(&buffer[..read]);
366        stable
367            .write_all(&buffer[..read])
368            .map_err(|error| invalid_artifact(source_path, error))?;
369    }
370    set_stable_permissions(&stable_path, source_metadata)
371        .map_err(|error| invalid_artifact(source_path, error))?;
372    Ok((
373        ArtifactBacking {
374            path: stable_path,
375            _directory: directory,
376        },
377        format!("sha256:{}", hex::encode(hasher.finalize())),
378        size,
379    ))
380}
381
382fn stable_artifact_directory(
383    source_path: &Path,
384    staging_root: Option<&Path>,
385) -> Result<tempfile::TempDir, RuntimeFailure> {
386    let builder = || {
387        let mut builder = tempfile::Builder::new();
388        builder.prefix("lenso-artifact-");
389        builder
390    };
391    if let Some(root) = staging_root {
392        return builder()
393            .tempdir_in(root)
394            .map_err(|error| invalid_artifact(source_path, error));
395    }
396    builder()
397        .tempdir()
398        .map_err(|error| invalid_artifact(source_path, error))
399}
400
401#[cfg(unix)]
402fn set_stable_permissions(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
403    use std::os::unix::fs::PermissionsExt as _;
404
405    let mode = metadata.permissions().mode() & 0o555;
406    fs::set_permissions(path, fs::Permissions::from_mode(mode))
407}
408
409#[cfg(not(unix))]
410fn set_stable_permissions(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
411    let mut permissions = metadata.permissions();
412    permissions.set_readonly(true);
413    fs::set_permissions(path, permissions)
414}
415
416/// Immutable Instance-to-Artifact mapping injected by the Generation Supervisor.
417#[derive(Clone, Debug, Default)]
418pub struct ArtifactCatalog(BTreeMap<String, ArtifactHandle>);
419
420impl ArtifactCatalog {
421    /// Creates an empty catalog for an Adapter with no selected Instances.
422    pub fn new() -> Self {
423        Self::default()
424    }
425
426    /// Adds one exact execution input and rejects duplicate Instance authority.
427    pub fn with_artifact(
428        mut self,
429        instance_key: impl Into<String>,
430        artifact: ArtifactHandle,
431    ) -> Result<Self, RuntimeFailure> {
432        let instance_key = instance_key.into();
433        if self.0.insert(instance_key.clone(), artifact).is_some() {
434            return Err(RuntimeFailure::InvalidResolvedPlan {
435                detail: format!("duplicate Artifact authority for Instance `{instance_key}`"),
436            });
437        }
438        Ok(self)
439    }
440
441    /// Resolves the one selected execution input for an Instance.
442    pub fn require(&self, instance_key: &str) -> Result<&ArtifactHandle, RuntimeFailure> {
443        self.0
444            .get(instance_key)
445            .ok_or_else(|| RuntimeFailure::InvalidResolvedPlan {
446                detail: format!("no admitted Artifact for Instance `{instance_key}`"),
447            })
448    }
449}
450
451/// Generated typed-value bridge shared by byte-oriented Execution Adapters.
452pub trait JsonCapabilityCodec: std::fmt::Debug + 'static {
453    /// Stable Capability series identity.
454    fn capability_id(&self) -> &'static str;
455    /// Exact Descriptor version.
456    fn descriptor_version(&self) -> &'static str;
457    /// Canonical digest of the exact generated Descriptor.
458    ///
459    /// Codecs generated before this method existed return an empty value and
460    /// remain usable by legacy profiles. Authoring V2 profiles must reject an
461    /// empty or malformed digest before readiness.
462    fn descriptor_digest(&self) -> &'static str {
463        ""
464    }
465    /// Exact request Operation table.
466    fn request_operations(&self) -> &'static [&'static str];
467    /// Exact bidirectional stream Operation table.
468    fn stream_operations(&self) -> &'static [&'static str] {
469        &[]
470    }
471    /// Converts one generated request into validated portable JSON.
472    fn encode_request(&self, operation: &str, request: &dyn Any) -> Result<Value, RuntimeFailure>;
473    /// Converts portable JSON into the generated response value.
474    fn decode_response(
475        &self,
476        operation: &str,
477        value: Value,
478    ) -> Result<Box<dyn Any>, RuntimeFailure>;
479    /// Converts portable JSON into the generated Domain Error value.
480    fn decode_domain_error(
481        &self,
482        operation: &str,
483        value: Value,
484    ) -> Result<Box<dyn Any>, RuntimeFailure>;
485    /// Converts one generated stream-open request into validated portable JSON.
486    fn encode_stream_open(
487        &self,
488        operation: &str,
489        request: &dyn Any,
490    ) -> Result<Value, RuntimeFailure> {
491        let _ = request;
492        Err(unknown_operation(self.capability_id(), operation))
493    }
494    /// Converts one generated outbound stream message into validated portable JSON.
495    fn encode_stream_message(
496        &self,
497        operation: &str,
498        message: &dyn Any,
499    ) -> Result<Value, RuntimeFailure> {
500        let _ = message;
501        Err(unknown_operation(self.capability_id(), operation))
502    }
503    /// Converts one portable JSON stream message into its generated value.
504    fn decode_stream_message(
505        &self,
506        operation: &str,
507        value: Value,
508    ) -> Result<Box<dyn Any>, RuntimeFailure> {
509        let _ = value;
510        Err(unknown_operation(self.capability_id(), operation))
511    }
512    /// Converts one portable JSON stream terminal error into its generated value.
513    fn decode_stream_domain_error(
514        &self,
515        operation: &str,
516        value: Value,
517    ) -> Result<Box<dyn Any>, RuntimeFailure> {
518        let _ = value;
519        Err(unknown_operation(self.capability_id(), operation))
520    }
521    /// Invokes one exact Plan-bound host Request dependency from portable JSON.
522    fn invoke_host_request(
523        &self,
524        dependency: PluginDependencyHandle,
525        operation: String,
526        request: Value,
527        context: InvocationContext,
528    ) -> JsonHostRequestFuture {
529        let _ = (dependency, request, context);
530        Box::pin(futures::future::ready(Err(unknown_operation(
531            self.capability_id(),
532            &operation,
533        ))))
534    }
535    /// Opens one exact Plan-bound host Stream dependency from portable JSON.
536    fn open_host_stream(
537        &self,
538        dependency: PluginStreamDependencyHandle,
539        operation: String,
540        request: Value,
541        context: InvocationContext,
542    ) -> JsonHostStreamOpenFuture {
543        let _ = (dependency, request, context);
544        Box::pin(futures::future::ready(Err(unknown_operation(
545            self.capability_id(),
546            &operation,
547        ))))
548    }
549}
550
551/// Exact host outcome returned by a byte-oriented Plugin invocation.
552#[derive(Debug)]
553pub enum JsonInvocationOutcome {
554    /// Successful generated response value.
555    Success(Value),
556    /// Declared generated Domain Error value.
557    DomainError(Value),
558}
559
560/// Projects a Runtime Failure into a bounded, secret-free guest ABI value.
561pub fn json_runtime_failure(error: &RuntimeFailure) -> Value {
562    match error {
563        RuntimeFailure::Unavailable { capability } => serde_json::json!({
564            "kind": "unavailable",
565            "capability": capability,
566        }),
567        RuntimeFailure::UnknownOperation {
568            capability,
569            operation,
570        } => serde_json::json!({
571            "kind": "unknown_operation",
572            "capability": capability,
573            "operation": operation,
574        }),
575        RuntimeFailure::AmbiguousBinding {
576            capability,
577            providers,
578        } => serde_json::json!({
579            "kind": "ambiguous_binding",
580            "capability": capability,
581            "providers": providers,
582        }),
583        RuntimeFailure::ProtocolViolation { capability } => serde_json::json!({
584            "kind": "protocol_violation",
585            "capability": capability,
586        }),
587        RuntimeFailure::AdmissionClosed => serde_json::json!({ "kind": "admission_closed" }),
588        RuntimeFailure::ResourceExhausted {
589            capability,
590            operation,
591        } => serde_json::json!({
592            "kind": "resource_exhausted",
593            "capability": capability,
594            "operation": operation,
595        }),
596        RuntimeFailure::DeadlineExceeded { request_id } => serde_json::json!({
597            "kind": "deadline_exceeded",
598            "request_id": request_id.to_string(),
599        }),
600        RuntimeFailure::Cancelled { request_id } => serde_json::json!({
601            "kind": "cancelled",
602            "request_id": request_id.to_string(),
603        }),
604        RuntimeFailure::MissingPluginFactory { .. }
605        | RuntimeFailure::UnavailableExecutionClass { .. }
606        | RuntimeFailure::InvalidResolvedPlan { .. }
607        | RuntimeFailure::Internal { .. }
608        | RuntimeFailure::PluginFailure { .. }
609        | RuntimeFailure::PluginRestartExhausted { .. } => {
610            serde_json::json!({ "kind": "internal" })
611        }
612    }
613}
614
615/// Encodes a host import Request result into the stable guest envelope.
616pub fn json_host_invocation_envelope(
617    outcome: Result<JsonInvocationOutcome, RuntimeFailure>,
618) -> Value {
619    match outcome {
620        Ok(JsonInvocationOutcome::Success(value)) => serde_json::json!({ "ok": value }),
621        Ok(JsonInvocationOutcome::DomainError(value)) => serde_json::json!({ "error": value }),
622        Err(error) => serde_json::json!({ "runtime": json_runtime_failure(&error) }),
623    }
624}
625
626/// Result of one Plan-bound host Request import after generated value translation.
627pub type JsonHostRequestFuture =
628    futures::future::LocalBoxFuture<'static, Result<JsonInvocationOutcome, RuntimeFailure>>;
629
630/// Adapter-neutral host Stream session exposed to a byte-oriented guest.
631pub trait JsonHostStreamSession: std::fmt::Debug + 'static {
632    fn send(
633        self: Rc<Self>,
634        message: Value,
635    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>>;
636    fn receive(
637        self: Rc<Self>,
638    ) -> futures::future::LocalBoxFuture<'static, Result<JsonStreamItem, RuntimeFailure>>;
639    fn close_send(
640        self: Rc<Self>,
641    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>>;
642    fn cancel(&self);
643}
644
645/// Result of opening one Plan-bound host Stream import.
646pub type JsonHostStreamOpenFuture = futures::future::LocalBoxFuture<
647    'static,
648    Result<Result<Rc<dyn JsonHostStreamSession>, Value>, RuntimeFailure>,
649>;
650
651type DecodeStreamMessage<C> =
652    Rc<dyn Fn(Value) -> Result<<C as StreamCapability>::Message, RuntimeFailure>>;
653type EncodeStreamMessage<C> =
654    Rc<dyn Fn(<C as StreamCapability>::Message) -> Result<Value, RuntimeFailure>>;
655type EncodeStreamError<C> =
656    Rc<dyn Fn(<C as StreamCapability>::DomainError) -> Result<Value, RuntimeFailure>>;
657
658/// Wraps one generated typed host Stream as portable JSON for a guest import.
659pub fn json_host_stream<C: StreamCapability>(
660    stream: NativeStream<C>,
661    decode_message: impl Fn(Value) -> Result<C::Message, RuntimeFailure> + 'static,
662    encode_message: impl Fn(C::Message) -> Result<Value, RuntimeFailure> + 'static,
663    encode_error: impl Fn(C::DomainError) -> Result<Value, RuntimeFailure> + 'static,
664) -> Rc<dyn JsonHostStreamSession> {
665    Rc::new(TypedJsonHostStream {
666        stream: Rc::new(stream),
667        decode_message: Rc::new(decode_message),
668        encode_message: Rc::new(encode_message),
669        encode_error: Rc::new(encode_error),
670    })
671}
672
673struct TypedJsonHostStream<C: StreamCapability> {
674    stream: Rc<NativeStream<C>>,
675    decode_message: DecodeStreamMessage<C>,
676    encode_message: EncodeStreamMessage<C>,
677    encode_error: EncodeStreamError<C>,
678}
679
680impl<C: StreamCapability> std::fmt::Debug for TypedJsonHostStream<C> {
681    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
682        formatter
683            .debug_struct("TypedJsonHostStream")
684            .field("capability", &C::ID)
685            .finish_non_exhaustive()
686    }
687}
688
689impl<C: StreamCapability> JsonHostStreamSession for TypedJsonHostStream<C> {
690    fn send(
691        self: Rc<Self>,
692        message: Value,
693    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
694        Box::pin(async move {
695            let message = (self.decode_message)(message)?;
696            self.stream.send(message).await
697        })
698    }
699
700    fn receive(
701        self: Rc<Self>,
702    ) -> futures::future::LocalBoxFuture<'static, Result<JsonStreamItem, RuntimeFailure>> {
703        Box::pin(async move {
704            match self.stream.receive().await? {
705                StreamEvent::Message(message) => {
706                    (self.encode_message)(message).map(JsonStreamItem::Message)
707                }
708                StreamEvent::PeerHalfClosed => Ok(JsonStreamItem::PeerHalfClosed),
709                StreamEvent::Terminal(Ok(())) => Ok(JsonStreamItem::Terminal(Ok(()))),
710                StreamEvent::Terminal(Err(error)) => {
711                    (self.encode_error)(error).map(|error| JsonStreamItem::Terminal(Err(error)))
712                }
713            }
714        })
715    }
716
717    fn close_send(
718        self: Rc<Self>,
719    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
720        Box::pin(async move { self.stream.close_send().await })
721    }
722
723    fn cancel(&self) {
724        self.stream.cancel();
725    }
726}
727
728/// Stable request-only guest ABI implemented by byte-oriented Plugin runtimes.
729pub const JSON_REQUEST_ABI_V1: &str = "lenso.json-request@1";
730
731/// Stable Request and bidirectional Stream guest ABI.
732pub const JSON_INTERACTIONS_ABI_V1: &str = "lenso.json-interactions@1";
733
734/// Stable Request, Stream, and Plan-bound host Capability import ABI.
735pub const JSON_HOST_IMPORTS_ABI_V1: &str = "lenso.json-host-imports@1";
736/// Stable Request, Stream, and named Plan-bound Host Capability import ABI.
737pub const JSON_HOST_IMPORTS_ABI_V2: &str = "lenso.json-host-imports@2";
738
739/// Exact guest declaration returned before an Adapter opens readiness.
740#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
741#[serde(deny_unknown_fields)]
742pub struct JsonPluginDescriptor {
743    pub abi: String,
744    pub capabilities: Vec<JsonCapabilityDescriptor>,
745    #[serde(default, skip_serializing_if = "Vec::is_empty")]
746    pub required_capabilities: Vec<JsonRequiredCapabilityDescriptor>,
747}
748
749/// One exact request Capability exposed by a guest Plugin.
750#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
751#[serde(deny_unknown_fields)]
752pub struct JsonCapabilityDescriptor {
753    pub capability_id: String,
754    pub descriptor_version: String,
755    pub request_operations: Vec<String>,
756    #[serde(default, skip_serializing_if = "Vec::is_empty")]
757    pub stream_operations: Vec<String>,
758}
759
760/// One exact Capability requirement declared by a guest Plugin.
761#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
762#[serde(deny_unknown_fields)]
763pub struct JsonRequiredCapabilityDescriptor {
764    pub requirement_id: String,
765    pub capability_id: String,
766    pub descriptor_version: String,
767    pub cardinality: CapabilityCardinality,
768}
769
770/// Derives the only guest declaration accepted for one resolved Instance.
771pub fn expected_json_plugin_descriptor(
772    instance: &PluginInstancePlan,
773) -> Result<JsonPluginDescriptor, RuntimeFailure> {
774    let mut capabilities = Vec::with_capacity(instance.provided_capabilities().len());
775    for descriptor in instance.provided_capabilities() {
776        if !descriptor.event_operations().is_empty() {
777            return Err(RuntimeFailure::InvalidResolvedPlan {
778                detail: format!(
779                    "Execution class `{}` does not support Event endpoints",
780                    instance.execution_class()
781                ),
782            });
783        }
784        capabilities.push(JsonCapabilityDescriptor {
785            capability_id: descriptor.capability_id().to_owned(),
786            descriptor_version: descriptor.descriptor_version().to_owned(),
787            request_operations: descriptor
788                .request_operations()
789                .into_iter()
790                .map(str::to_owned)
791                .collect(),
792            stream_operations: descriptor
793                .stream_operations()
794                .into_iter()
795                .map(str::to_owned)
796                .collect(),
797        });
798    }
799    capabilities.sort();
800    if capabilities
801        .windows(2)
802        .any(|pair| pair[0].capability_id == pair[1].capability_id)
803    {
804        return Err(RuntimeFailure::InvalidResolvedPlan {
805            detail: format!(
806                "Instance `{}` declares a duplicate Capability",
807                instance.instance_key()
808            ),
809        });
810    }
811    let mut required_capabilities = instance
812        .required_capabilities()
813        .iter()
814        .map(|requirement| JsonRequiredCapabilityDescriptor {
815            requirement_id: requirement.requirement_id().to_owned(),
816            capability_id: requirement.capability_id().to_owned(),
817            descriptor_version: requirement.descriptor_version().to_owned(),
818            cardinality: requirement.cardinality(),
819        })
820        .collect::<Vec<_>>();
821    sort_required_capabilities(&mut required_capabilities);
822    Ok(JsonPluginDescriptor {
823        abi: if !required_capabilities.is_empty() {
824            JSON_HOST_IMPORTS_ABI_V2
825        } else if capabilities
826            .iter()
827            .any(|capability| !capability.stream_operations.is_empty())
828        {
829            JSON_INTERACTIONS_ABI_V1
830        } else {
831            JSON_REQUEST_ABI_V1
832        }
833        .to_owned(),
834        capabilities,
835        required_capabilities,
836    })
837}
838
839/// Parses and compares a guest Ready declaration with exact Plan authority.
840pub fn validate_json_plugin_descriptor(
841    instance: &PluginInstancePlan,
842    encoded: &str,
843) -> Result<(), RuntimeFailure> {
844    let mut actual = serde_json::from_str::<JsonPluginDescriptor>(encoded).map_err(|_| {
845        RuntimeFailure::ProtocolViolation {
846            capability: "lenso.json-request@1",
847        }
848    })?;
849    actual.capabilities.sort();
850    sort_required_capabilities(&mut actual.required_capabilities);
851    let expected = expected_json_plugin_descriptor(instance)?;
852    if actual != expected {
853        return Err(RuntimeFailure::InvalidResolvedPlan {
854            detail: format!(
855                "guest descriptor does not match resolved Instance `{}`",
856                instance.instance_key()
857            ),
858        });
859    }
860    Ok(())
861}
862
863fn sort_required_capabilities(requirements: &mut [JsonRequiredCapabilityDescriptor]) {
864    requirements.sort_by(|left, right| {
865        (
866            &left.requirement_id,
867            &left.capability_id,
868            &left.descriptor_version,
869            cardinality_order(left.cardinality),
870        )
871            .cmp(&(
872                &right.requirement_id,
873                &right.capability_id,
874                &right.descriptor_version,
875                cardinality_order(right.cardinality),
876            ))
877    });
878}
879
880const fn cardinality_order(cardinality: CapabilityCardinality) -> u8 {
881    match cardinality {
882        CapabilityCardinality::One => 0,
883        CapabilityCardinality::Optional => 1,
884        CapabilityCardinality::Many => 2,
885    }
886}
887
888/// Guest transport seam shared by Wasm Component and embedded-JavaScript Adapters.
889pub trait JsonRequestTransport: std::fmt::Debug + 'static {
890    fn invoke(
891        self: Rc<Self>,
892        capability: String,
893        operation: String,
894        request_json: String,
895        context: InvocationContext,
896    ) -> futures::future::LocalBoxFuture<'static, Result<JsonInvocationOutcome, RuntimeFailure>>;
897}
898
899/// One exact transport frame received from a byte-oriented guest stream.
900#[derive(Debug)]
901pub enum JsonStreamItem {
902    Message(Value),
903    PeerHalfClosed,
904    Terminal(Result<(), Value>),
905}
906
907/// Canonical portable JSON frame returned by `stream-receive` guest exports.
908#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
909#[serde(
910    tag = "kind",
911    content = "value",
912    rename_all = "kebab-case",
913    deny_unknown_fields
914)]
915pub enum JsonStreamFrame {
916    Message(Value),
917    PeerHalfClosed,
918    TerminalSuccess,
919    TerminalError(Value),
920}
921
922/// One exact Plan binding exposed to a guest Plugin after lifecycle activation.
923#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
924pub struct JsonHostBindingDescriptor {
925    pub binding_id: u32,
926    pub requirement_id: String,
927    pub provider_instance: String,
928    pub capability_id: String,
929    pub descriptor_version: String,
930    #[serde(skip_serializing)]
931    pub descriptor_digest: String,
932    pub request_operations: Vec<String>,
933    pub stream_operations: Vec<String>,
934}
935
936#[derive(Clone)]
937struct JsonHostBinding {
938    descriptor: JsonHostBindingDescriptor,
939    codec: Rc<dyn JsonCapabilityCodec>,
940    request: Option<PluginDependencyHandle>,
941    stream: Option<PluginStreamDependencyHandle>,
942}
943
944impl std::fmt::Debug for JsonHostBinding {
945    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
946        formatter
947            .debug_struct("JsonHostBinding")
948            .field("descriptor", &self.descriptor)
949            .finish_non_exhaustive()
950    }
951}
952
953/// Activated, Plan-bound Capability imports for one byte-oriented guest generation.
954#[derive(Debug)]
955pub struct JsonHostImports {
956    codecs: BTreeMap<String, Rc<dyn JsonCapabilityCodec>>,
957    bindings: std::cell::RefCell<Option<Vec<JsonHostBinding>>>,
958    streams: std::cell::RefCell<BTreeMap<u64, Rc<dyn JsonHostStreamSession>>>,
959    next_stream_id: std::cell::Cell<u64>,
960    max_streams: usize,
961}
962
963impl JsonHostImports {
964    /// Creates a closed import table from the exact generated requirement codecs.
965    pub fn new(
966        codecs: Vec<Rc<dyn JsonCapabilityCodec>>,
967        max_streams: usize,
968    ) -> Result<Self, RuntimeFailure> {
969        let mut by_capability = BTreeMap::new();
970        for codec in codecs {
971            let capability = codec.capability_id().to_owned();
972            if let Some(existing) = by_capability.get(&capability) {
973                if !Rc::ptr_eq(existing, &codec) {
974                    return Err(RuntimeFailure::InvalidResolvedPlan {
975                        detail: format!(
976                            "conflicting guest import codecs for Capability `{capability}`"
977                        ),
978                    });
979                }
980            } else {
981                by_capability.insert(capability, codec);
982            }
983        }
984        Ok(Self {
985            codecs: by_capability,
986            bindings: std::cell::RefCell::new(None),
987            streams: std::cell::RefCell::new(BTreeMap::new()),
988            next_stream_id: std::cell::Cell::new(1),
989            max_streams,
990        })
991    }
992
993    /// Installs only the dependencies materialized from the immutable Plan.
994    pub fn activate(&self, dependencies: &PluginDependencies) -> Result<(), RuntimeFailure> {
995        if self.bindings.borrow().is_some() {
996            return Err(RuntimeFailure::Internal {
997                detail: "guest Capability imports were activated twice".to_owned(),
998            });
999        }
1000        let mut bindings = Vec::with_capacity(dependencies.len());
1001        for (index, dependency) in dependencies.bindings().iter().enumerate() {
1002            let codec = self
1003                .codecs
1004                .get(dependency.capability_id())
1005                .cloned()
1006                .ok_or_else(|| RuntimeFailure::InvalidResolvedPlan {
1007                    detail: format!(
1008                        "no generated guest import codec for Capability `{}`",
1009                        dependency.capability_id()
1010                    ),
1011                })?;
1012            let request = dependency.handle();
1013            let stream = dependency.stream_handle();
1014            validate_host_binding(&codec, request.as_ref(), stream.as_ref())?;
1015            let binding_id =
1016                u32::try_from(index).map_err(|_| RuntimeFailure::InvalidResolvedPlan {
1017                    detail: "guest import binding table exceeds u32 identity space".to_owned(),
1018                })?;
1019            bindings.push(JsonHostBinding {
1020                descriptor: JsonHostBindingDescriptor {
1021                    binding_id,
1022                    requirement_id: dependency.requirement_id().to_owned(),
1023                    provider_instance: dependency.provider_instance().to_owned(),
1024                    capability_id: dependency.capability_id().to_owned(),
1025                    descriptor_version: codec.descriptor_version().to_owned(),
1026                    descriptor_digest: codec.descriptor_digest().to_owned(),
1027                    request_operations: request.as_ref().map_or_else(Vec::new, |handle| {
1028                        handle
1029                            .operations()
1030                            .iter()
1031                            .map(|item| (*item).to_owned())
1032                            .collect()
1033                    }),
1034                    stream_operations: stream.as_ref().map_or_else(Vec::new, |handle| {
1035                        handle
1036                            .operations()
1037                            .iter()
1038                            .map(|item| (*item).to_owned())
1039                            .collect()
1040                    }),
1041                },
1042                codec,
1043                request,
1044                stream,
1045            });
1046        }
1047        self.bindings.replace(Some(bindings));
1048        Ok(())
1049    }
1050
1051    /// Returns the exact activated binding table in resolved provider order.
1052    pub fn descriptors(&self) -> Result<Vec<JsonHostBindingDescriptor>, RuntimeFailure> {
1053        self.bindings
1054            .borrow()
1055            .as_ref()
1056            .map(|bindings| {
1057                bindings
1058                    .iter()
1059                    .map(|binding| binding.descriptor.clone())
1060                    .collect()
1061            })
1062            .ok_or(RuntimeFailure::AdmissionClosed)
1063    }
1064
1065    /// Invokes one activated Request binding by its unforgeable table index.
1066    pub fn invoke(
1067        &self,
1068        binding_id: u32,
1069        operation: String,
1070        request: Value,
1071        context: InvocationContext,
1072    ) -> JsonHostRequestFuture {
1073        let binding = match self.binding(binding_id) {
1074            Ok(binding) => binding,
1075            Err(error) => return Box::pin(futures::future::ready(Err(error))),
1076        };
1077        let Some(dependency) = binding.request else {
1078            return Box::pin(futures::future::ready(Err(
1079                RuntimeFailure::UnknownOperation {
1080                    capability: binding.codec.capability_id(),
1081                    operation,
1082                },
1083            )));
1084        };
1085        binding
1086            .codec
1087            .invoke_host_request(dependency, operation, request, context)
1088    }
1089
1090    /// Opens one activated Stream binding and assigns an Adapter-local import id.
1091    pub fn open_stream(
1092        self: Rc<Self>,
1093        binding_id: u32,
1094        operation: String,
1095        request: Value,
1096        context: InvocationContext,
1097    ) -> futures::future::LocalBoxFuture<'static, Result<Result<u64, Value>, RuntimeFailure>> {
1098        Box::pin(async move {
1099            if self.streams.borrow().len() >= self.max_streams {
1100                return Err(RuntimeFailure::ResourceExhausted {
1101                    capability: JSON_HOST_IMPORTS_ABI_V2,
1102                    operation: "stream-open".to_owned(),
1103                });
1104            }
1105            let binding = self.binding(binding_id)?;
1106            let dependency = binding
1107                .stream
1108                .ok_or_else(|| RuntimeFailure::UnknownOperation {
1109                    capability: binding.codec.capability_id(),
1110                    operation: operation.clone(),
1111                })?;
1112            match binding
1113                .codec
1114                .open_host_stream(dependency, operation, request, context)
1115                .await?
1116            {
1117                Ok(stream) => {
1118                    let stream_id = self.next_stream_id.get();
1119                    let next =
1120                        stream_id
1121                            .checked_add(1)
1122                            .ok_or(RuntimeFailure::ResourceExhausted {
1123                                capability: JSON_HOST_IMPORTS_ABI_V2,
1124                                operation: "stream-open".to_owned(),
1125                            })?;
1126                    self.next_stream_id.set(next);
1127                    self.streams.borrow_mut().insert(stream_id, stream);
1128                    Ok(Ok(stream_id))
1129                }
1130                Err(error) => Ok(Err(error)),
1131            }
1132        })
1133    }
1134
1135    /// Sends one portable message through a guest-owned host Stream.
1136    pub fn send_stream(
1137        &self,
1138        stream_id: u64,
1139        message: Value,
1140    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
1141        match self.stream(stream_id) {
1142            Ok(stream) => stream.send(message),
1143            Err(error) => Box::pin(futures::future::ready(Err(error))),
1144        }
1145    }
1146
1147    /// Receives the next portable frame from one guest-owned host Stream.
1148    pub fn receive_stream(
1149        self: Rc<Self>,
1150        stream_id: u64,
1151    ) -> futures::future::LocalBoxFuture<'static, Result<JsonStreamItem, RuntimeFailure>> {
1152        Box::pin(async move {
1153            let stream = self.stream(stream_id)?;
1154            let item = stream.receive().await?;
1155            if matches!(item, JsonStreamItem::Terminal(_)) {
1156                self.streams.borrow_mut().remove(&stream_id);
1157            }
1158            Ok(item)
1159        })
1160    }
1161
1162    /// Half-closes the guest-to-host direction of one guest-owned host Stream.
1163    pub fn close_stream_send(
1164        &self,
1165        stream_id: u64,
1166    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
1167        match self.stream(stream_id) {
1168            Ok(stream) => stream.close_send(),
1169            Err(error) => Box::pin(futures::future::ready(Err(error))),
1170        }
1171    }
1172
1173    /// Cancels and removes one guest-owned host Stream.
1174    pub fn cancel_stream(&self, stream_id: u64) -> Result<(), RuntimeFailure> {
1175        let stream = self
1176            .streams
1177            .borrow_mut()
1178            .remove(&stream_id)
1179            .ok_or_else(unknown_host_stream)?;
1180        stream.cancel();
1181        Ok(())
1182    }
1183
1184    /// Closes admission and cancels every import Stream owned by this generation.
1185    pub fn deactivate(&self) {
1186        self.bindings.replace(None);
1187        for (_, stream) in std::mem::take(&mut *self.streams.borrow_mut()) {
1188            stream.cancel();
1189        }
1190    }
1191
1192    fn binding(&self, binding_id: u32) -> Result<JsonHostBinding, RuntimeFailure> {
1193        let bindings = self.bindings.borrow();
1194        let bindings = bindings.as_ref().ok_or(RuntimeFailure::AdmissionClosed)?;
1195        bindings
1196            .get(binding_id as usize)
1197            .cloned()
1198            .ok_or(RuntimeFailure::ProtocolViolation {
1199                capability: JSON_HOST_IMPORTS_ABI_V2,
1200            })
1201    }
1202
1203    fn stream(&self, stream_id: u64) -> Result<Rc<dyn JsonHostStreamSession>, RuntimeFailure> {
1204        self.streams
1205            .borrow()
1206            .get(&stream_id)
1207            .cloned()
1208            .ok_or_else(unknown_host_stream)
1209    }
1210}
1211
1212fn validate_host_binding(
1213    codec: &Rc<dyn JsonCapabilityCodec>,
1214    request: Option<&PluginDependencyHandle>,
1215    stream: Option<&PluginStreamDependencyHandle>,
1216) -> Result<(), RuntimeFailure> {
1217    for (capability, version) in request
1218        .map(|handle| (handle.capability_id(), handle.descriptor_version()))
1219        .into_iter()
1220        .chain(stream.map(|handle| (handle.capability_id(), handle.descriptor_version())))
1221    {
1222        if capability != codec.capability_id() || version != codec.descriptor_version() {
1223            return Err(RuntimeFailure::ProtocolViolation {
1224                capability: codec.capability_id(),
1225            });
1226        }
1227    }
1228    Ok(())
1229}
1230
1231fn unknown_host_stream() -> RuntimeFailure {
1232    RuntimeFailure::ProtocolViolation {
1233        capability: JSON_HOST_IMPORTS_ABI_V2,
1234    }
1235}
1236
1237impl JsonStreamFrame {
1238    /// Parses one bounded guest result into the Adapter-neutral transport item.
1239    pub fn decode(
1240        encoded: &str,
1241        capability: &'static str,
1242    ) -> Result<JsonStreamItem, RuntimeFailure> {
1243        match serde_json::from_str(encoded)
1244            .map_err(|_| RuntimeFailure::ProtocolViolation { capability })?
1245        {
1246            Self::Message(value) => Ok(JsonStreamItem::Message(value)),
1247            Self::PeerHalfClosed => Ok(JsonStreamItem::PeerHalfClosed),
1248            Self::TerminalSuccess => Ok(JsonStreamItem::Terminal(Ok(()))),
1249            Self::TerminalError(value) => Ok(JsonStreamItem::Terminal(Err(value))),
1250        }
1251    }
1252}
1253
1254/// Adapter-owned transport session for the portable JSON Stream ABI.
1255pub trait JsonStreamSessionTransport: std::fmt::Debug + 'static {
1256    fn send(
1257        self: Rc<Self>,
1258        message_json: String,
1259    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>>;
1260    fn receive(
1261        self: Rc<Self>,
1262    ) -> futures::future::LocalBoxFuture<'static, Result<JsonStreamItem, RuntimeFailure>>;
1263    fn close_send(
1264        self: Rc<Self>,
1265    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>>;
1266    fn cancel(&self);
1267}
1268
1269/// Adapter-owned result of opening one portable JSON stream transport session.
1270pub type JsonStreamOpenFuture = futures::future::LocalBoxFuture<
1271    'static,
1272    Result<Result<Rc<dyn JsonStreamSessionTransport>, Value>, RuntimeFailure>,
1273>;
1274
1275/// Guest transport seam shared by Stream-capable byte-oriented Adapters.
1276pub trait JsonStreamTransport: std::fmt::Debug + 'static {
1277    fn open(
1278        self: Rc<Self>,
1279        capability: String,
1280        operation: String,
1281        request_json: String,
1282        context: InvocationContext,
1283    ) -> JsonStreamOpenFuture;
1284}
1285
1286/// Builds typed Kernel endpoints over one exact guest transport generation.
1287pub fn json_request_endpoints<T: JsonRequestTransport>(
1288    transport: Rc<T>,
1289    codecs: Vec<Rc<dyn JsonCapabilityCodec>>,
1290) -> Vec<Rc<dyn NativeRequestEndpoint>> {
1291    let transport: Rc<dyn JsonRequestTransport> = transport;
1292    codecs
1293        .into_iter()
1294        .filter(|codec| !codec.request_operations().is_empty())
1295        .map(|codec| {
1296            Rc::new(JsonRequestEndpoint {
1297                transport: transport.clone(),
1298                codec,
1299            }) as Rc<dyn NativeRequestEndpoint>
1300        })
1301        .collect()
1302}
1303
1304/// Builds typed Kernel Stream endpoints over one exact guest transport generation.
1305pub fn json_stream_endpoints<T: JsonStreamTransport>(
1306    transport: Rc<T>,
1307    codecs: Vec<Rc<dyn JsonCapabilityCodec>>,
1308) -> Vec<Rc<dyn NativeStreamEndpoint>> {
1309    let transport: Rc<dyn JsonStreamTransport> = transport;
1310    codecs
1311        .into_iter()
1312        .filter(|codec| !codec.stream_operations().is_empty())
1313        .map(|codec| {
1314            Rc::new(JsonStreamEndpoint {
1315                transport: transport.clone(),
1316                codec,
1317            }) as Rc<dyn NativeStreamEndpoint>
1318        })
1319        .collect()
1320}
1321
1322#[derive(Debug)]
1323struct JsonStreamEndpoint {
1324    transport: Rc<dyn JsonStreamTransport>,
1325    codec: Rc<dyn JsonCapabilityCodec>,
1326}
1327
1328impl NativeStreamEndpoint for JsonStreamEndpoint {
1329    fn capability_id(&self) -> &'static str {
1330        self.codec.capability_id()
1331    }
1332    fn descriptor_version(&self) -> &'static str {
1333        self.codec.descriptor_version()
1334    }
1335    fn operations(&self) -> &'static [&'static str] {
1336        self.codec.stream_operations()
1337    }
1338
1339    fn open(
1340        &self,
1341        operation: &str,
1342        request: Box<dyn Any>,
1343        context: InvocationContext,
1344    ) -> futures::future::LocalBoxFuture<
1345        'static,
1346        Result<Result<Box<dyn NativeStreamSession>, Box<dyn Any>>, RuntimeFailure>,
1347    > {
1348        let transport = self.transport.clone();
1349        let codec = self.codec.clone();
1350        let operation = operation.to_owned();
1351        Box::pin(async move {
1352            if !codec.stream_operations().contains(&operation.as_str()) {
1353                return Err(unknown_operation(codec.capability_id(), &operation));
1354            }
1355            let request = codec.encode_stream_open(&operation, request.as_ref())?;
1356            let request_json =
1357                serde_json::to_string(&request).map_err(|_| RuntimeFailure::ProtocolViolation {
1358                    capability: codec.capability_id(),
1359                })?;
1360            match transport
1361                .open(
1362                    codec.capability_id().to_owned(),
1363                    operation.clone(),
1364                    request_json,
1365                    context,
1366                )
1367                .await?
1368            {
1369                Ok(session) => Ok(Ok(Box::new(JsonStreamSession {
1370                    session,
1371                    codec,
1372                    operation,
1373                }) as Box<dyn NativeStreamSession>)),
1374                Err(error) => codec.decode_stream_domain_error(&operation, error).map(Err),
1375            }
1376        })
1377    }
1378}
1379
1380#[derive(Debug)]
1381struct JsonStreamSession {
1382    session: Rc<dyn JsonStreamSessionTransport>,
1383    codec: Rc<dyn JsonCapabilityCodec>,
1384    operation: String,
1385}
1386
1387impl NativeStreamSession for JsonStreamSession {
1388    fn send(
1389        &self,
1390        message: Box<dyn Any>,
1391    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
1392        let encoded = self
1393            .codec
1394            .encode_stream_message(&self.operation, message.as_ref())
1395            .and_then(|value| {
1396                serde_json::to_string(&value).map_err(|_| RuntimeFailure::ProtocolViolation {
1397                    capability: self.codec.capability_id(),
1398                })
1399            });
1400        let session = self.session.clone();
1401        Box::pin(async move { session.send(encoded?).await })
1402    }
1403
1404    fn receive(
1405        &self,
1406    ) -> futures::future::LocalBoxFuture<'static, Result<NativeStreamItem, RuntimeFailure>> {
1407        let session = self.session.clone();
1408        let codec = self.codec.clone();
1409        let operation = self.operation.clone();
1410        Box::pin(async move {
1411            match session.receive().await? {
1412                JsonStreamItem::Message(value) => codec
1413                    .decode_stream_message(&operation, value)
1414                    .map(NativeStreamItem::Message),
1415                JsonStreamItem::PeerHalfClosed => Ok(NativeStreamItem::PeerHalfClosed),
1416                JsonStreamItem::Terminal(Ok(())) => Ok(NativeStreamItem::Terminal(Ok(()))),
1417                JsonStreamItem::Terminal(Err(value)) => codec
1418                    .decode_stream_domain_error(&operation, value)
1419                    .map(|error| NativeStreamItem::Terminal(Err(error))),
1420            }
1421        })
1422    }
1423
1424    fn close_send(&self) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
1425        self.session.clone().close_send()
1426    }
1427
1428    fn cancel(&self) {
1429        self.session.cancel();
1430    }
1431}
1432
1433#[derive(Debug)]
1434struct JsonRequestEndpoint {
1435    transport: Rc<dyn JsonRequestTransport>,
1436    codec: Rc<dyn JsonCapabilityCodec>,
1437}
1438
1439impl NativeRequestEndpoint for JsonRequestEndpoint {
1440    fn capability_id(&self) -> &'static str {
1441        self.codec.capability_id()
1442    }
1443
1444    fn descriptor_version(&self) -> &'static str {
1445        self.codec.descriptor_version()
1446    }
1447
1448    fn operations(&self) -> &'static [&'static str] {
1449        self.codec.request_operations()
1450    }
1451
1452    fn invoke(
1453        &self,
1454        operation: &str,
1455        request: Box<dyn Any>,
1456        context: InvocationContext,
1457    ) -> futures::future::LocalBoxFuture<
1458        'static,
1459        Result<Result<Box<dyn Any>, Box<dyn Any>>, RuntimeFailure>,
1460    > {
1461        let transport = self.transport.clone();
1462        let codec = self.codec.clone();
1463        let operation = operation.to_owned();
1464        Box::pin(async move {
1465            if !codec.request_operations().contains(&operation.as_str()) {
1466                return Err(RuntimeFailure::UnknownOperation {
1467                    capability: codec.capability_id(),
1468                    operation,
1469                });
1470            }
1471            let request = codec.encode_request(&operation, request.as_ref())?;
1472            let request =
1473                serde_json::to_string(&request).map_err(|_| RuntimeFailure::ProtocolViolation {
1474                    capability: codec.capability_id(),
1475                })?;
1476            match transport
1477                .invoke(
1478                    codec.capability_id().to_owned(),
1479                    operation.clone(),
1480                    request,
1481                    context,
1482                )
1483                .await?
1484            {
1485                JsonInvocationOutcome::Success(value) => {
1486                    codec.decode_response(&operation, value).map(Ok)
1487                }
1488                JsonInvocationOutcome::DomainError(value) => {
1489                    codec.decode_domain_error(&operation, value).map(Err)
1490                }
1491            }
1492        })
1493    }
1494}
1495
1496/// Validates Plan descriptors against registered generated codecs.
1497pub fn codecs_for_instance(
1498    instance: &PluginInstancePlan,
1499    codecs: &BTreeMap<String, Rc<dyn JsonCapabilityCodec>>,
1500) -> Result<Vec<Rc<dyn JsonCapabilityCodec>>, RuntimeFailure> {
1501    let mut selected = Vec::with_capacity(instance.provided_capabilities().len());
1502    for descriptor in instance.provided_capabilities() {
1503        if !descriptor.event_operations().is_empty() {
1504            return Err(RuntimeFailure::InvalidResolvedPlan {
1505                detail: format!(
1506                    "Execution class `{}` does not support Event endpoints",
1507                    instance.execution_class()
1508                ),
1509            });
1510        }
1511        let codec = codecs.get(descriptor.capability_id()).ok_or_else(|| {
1512            RuntimeFailure::InvalidResolvedPlan {
1513                detail: format!(
1514                    "no generated codec for Capability `{}`",
1515                    descriptor.capability_id()
1516                ),
1517            }
1518        })?;
1519        let request_operations: Vec<_> = codec
1520            .request_operations()
1521            .iter()
1522            .map(|operation| (*operation).to_owned())
1523            .collect();
1524        let stream_operations: Vec<_> = codec
1525            .stream_operations()
1526            .iter()
1527            .map(|operation| (*operation).to_owned())
1528            .collect();
1529        let expected_request: Vec<_> = descriptor
1530            .request_operations()
1531            .into_iter()
1532            .map(str::to_owned)
1533            .collect();
1534        let expected_stream: Vec<_> = descriptor
1535            .stream_operations()
1536            .into_iter()
1537            .map(str::to_owned)
1538            .collect();
1539        if codec.descriptor_version() != descriptor.descriptor_version()
1540            || request_operations != expected_request
1541            || stream_operations != expected_stream
1542        {
1543            return Err(RuntimeFailure::ProtocolViolation {
1544                capability: codec.capability_id(),
1545            });
1546        }
1547        selected.push(codec.clone());
1548    }
1549    Ok(selected)
1550}
1551
1552/// Validates every declared guest requirement against one registered generated codec.
1553pub fn codecs_for_requirements(
1554    instance: &PluginInstancePlan,
1555    codecs: &BTreeMap<String, Rc<dyn JsonCapabilityCodec>>,
1556) -> Result<Vec<Rc<dyn JsonCapabilityCodec>>, RuntimeFailure> {
1557    let mut selected = BTreeMap::new();
1558    for requirement in instance.required_capabilities() {
1559        let codec = codecs.get(requirement.capability_id()).ok_or_else(|| {
1560            RuntimeFailure::InvalidResolvedPlan {
1561                detail: format!(
1562                    "no generated guest import codec for Capability `{}`",
1563                    requirement.capability_id()
1564                ),
1565            }
1566        })?;
1567        if codec.descriptor_version() != requirement.descriptor_version() {
1568            return Err(RuntimeFailure::ProtocolViolation {
1569                capability: codec.capability_id(),
1570            });
1571        }
1572        selected
1573            .entry(requirement.capability_id().to_owned())
1574            .or_insert_with(|| codec.clone());
1575    }
1576    Ok(selected.into_values().collect())
1577}
1578
1579/// Builds exact request bindings from Adapter-prepared Plugin generations.
1580pub fn prepare_request_app(
1581    plan: &ResolvedAppPlan,
1582    execution_class: &ExecutionClassId,
1583    generations: BTreeMap<String, PreparedNativePlugin>,
1584) -> Result<PreparedNativeApp, RuntimeFailure> {
1585    let selected_instances = plan
1586        .plugin_instances()
1587        .iter()
1588        .filter(|instance| instance.execution_class() == execution_class)
1589        .map(|instance| instance.instance_key().to_owned())
1590        .collect::<std::collections::BTreeSet<_>>();
1591    let mut endpoints = BTreeMap::new();
1592    let mut stream_endpoints = BTreeMap::new();
1593    for (instance_key, generation) in &generations {
1594        for endpoint in generation.endpoints() {
1595            let identity = (instance_key.clone(), endpoint.capability_id().to_owned());
1596            if endpoints.insert(identity, endpoint.clone()).is_some() {
1597                return Err(RuntimeFailure::InvalidResolvedPlan {
1598                    detail: format!("duplicate request endpoint on Instance `{instance_key}`"),
1599                });
1600            }
1601        }
1602        for endpoint in generation.stream_endpoints() {
1603            let identity = (instance_key.clone(), endpoint.capability_id().to_owned());
1604            if stream_endpoints
1605                .insert(identity, endpoint.clone())
1606                .is_some()
1607            {
1608                return Err(RuntimeFailure::InvalidResolvedPlan {
1609                    detail: format!("duplicate stream endpoint on Instance `{instance_key}`"),
1610                });
1611            }
1612        }
1613    }
1614    for instance in plan
1615        .plugin_instances()
1616        .iter()
1617        .filter(|instance| selected_instances.contains(instance.instance_key()))
1618    {
1619        if !generations.contains_key(instance.instance_key()) {
1620            return Err(RuntimeFailure::InvalidResolvedPlan {
1621                detail: format!("Adapter omitted Instance `{}`", instance.instance_key()),
1622            });
1623        }
1624    }
1625    let mut bindings = Vec::new();
1626    let mut stream_bindings = Vec::new();
1627    for binding in plan.capability_bindings() {
1628        let key = (
1629            binding.provider_instance().to_owned(),
1630            binding.capability_id().to_owned(),
1631        );
1632        let request_endpoint = endpoints.get(&key);
1633        let stream_endpoint = stream_endpoints.get(&key);
1634        if let Some(endpoint) = request_endpoint {
1635            bindings.push(
1636                PreparedBinding::new(
1637                    binding.consumer_instance(),
1638                    binding.provider_instance(),
1639                    endpoint.clone(),
1640                )
1641                .with_requirement_id(binding.requirement_id()),
1642            );
1643        }
1644        if let Some(endpoint) = stream_endpoint {
1645            stream_bindings.push(
1646                PreparedStreamBinding::new(
1647                    binding.consumer_instance(),
1648                    binding.provider_instance(),
1649                    endpoint.clone(),
1650                )
1651                .with_requirement_id(binding.requirement_id()),
1652            );
1653        }
1654        if request_endpoint.is_none()
1655            && stream_endpoint.is_none()
1656            && selected_instances.contains(binding.provider_instance())
1657        {
1658            return Err(RuntimeFailure::InvalidResolvedPlan {
1659                detail: format!(
1660                    "Adapter omitted Capability `{}` endpoint for Instance `{}`",
1661                    binding.capability_id(),
1662                    binding.provider_instance()
1663                ),
1664            });
1665        }
1666    }
1667    Ok(PreparedNativeApp::new(bindings, generations).with_stream_bindings(stream_bindings))
1668}
1669
1670/// Looks up the exact codec and validates the Operation before dispatch.
1671pub fn require_operation(
1672    codecs: &BTreeMap<String, Rc<dyn JsonCapabilityCodec>>,
1673    capability_id: &str,
1674    operation: &str,
1675) -> Result<Rc<dyn JsonCapabilityCodec>, RuntimeFailure> {
1676    let codec =
1677        codecs
1678            .get(capability_id)
1679            .cloned()
1680            .ok_or_else(|| RuntimeFailure::InvalidResolvedPlan {
1681                detail: format!("no generated codec for Capability `{capability_id}`"),
1682            })?;
1683    if !codec.request_operations().contains(&operation) {
1684        return Err(RuntimeFailure::UnknownOperation {
1685            capability: codec.capability_id(),
1686            operation: operation.to_owned(),
1687        });
1688    }
1689    Ok(codec)
1690}
1691
1692fn unknown_operation(capability: &'static str, operation: &str) -> RuntimeFailure {
1693    RuntimeFailure::UnknownOperation {
1694        capability,
1695        operation: operation.to_owned(),
1696    }
1697}
1698
1699fn validate_digest(digest: &str) -> Result<(), RuntimeFailure> {
1700    let valid = digest.strip_prefix("sha256:").is_some_and(|hex| {
1701        hex.len() == 64
1702            && hex
1703                .bytes()
1704                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1705    });
1706    if valid {
1707        Ok(())
1708    } else {
1709        Err(RuntimeFailure::InvalidResolvedPlan {
1710            detail: format!("invalid canonical SHA-256 digest `{digest}`"),
1711        })
1712    }
1713}
1714
1715fn invalid_artifact(path: &Path, error: impl std::fmt::Display) -> RuntimeFailure {
1716    RuntimeFailure::InvalidResolvedPlan {
1717        detail: format!("cannot read Artifact `{}`: {error}", path.display()),
1718    }
1719}
1720
1721fn validate_resource_path(path: &str) -> Result<(), RuntimeFailure> {
1722    if path.is_empty()
1723        || path.starts_with('/')
1724        || path.contains(['\\', '\0'])
1725        || path
1726            .split('/')
1727            .any(|segment| segment.is_empty() || matches!(segment, "." | ".."))
1728    {
1729        return Err(invalid_resources(format!(
1730            "invalid Plugin resource path `{path}`"
1731        )));
1732    }
1733    Ok(())
1734}
1735
1736fn invalid_resources(detail: impl Into<String>) -> RuntimeFailure {
1737    RuntimeFailure::InvalidResolvedPlan {
1738        detail: detail.into(),
1739    }
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744    use std::io::Write;
1745
1746    use super::*;
1747
1748    #[test]
1749    fn artifact_handle_keeps_the_admitted_bytes_after_source_drift() {
1750        let mut file = tempfile::NamedTempFile::new().unwrap();
1751        file.write_all(b"first").unwrap();
1752        let digest = format!("sha256:{}", hex::encode(Sha256::digest(b"first")));
1753        let handle = ArtifactHandle::open(file.path(), &digest, 5).unwrap();
1754        file.as_file_mut().set_len(0).unwrap();
1755        file.write_all(b"other").unwrap();
1756        assert_eq!(handle.read_verified().unwrap(), b"first");
1757        assert_ne!(handle.path(), file.path());
1758        assert_eq!(fs::read(handle.path()).unwrap(), b"first");
1759    }
1760
1761    #[test]
1762    fn artifact_snapshot_survives_source_parent_rename_and_replacement() {
1763        let workspace = tempfile::tempdir().unwrap();
1764        let selected = workspace.path().join("selected");
1765        fs::create_dir(&selected).unwrap();
1766        let source = selected.join("plugin");
1767        fs::write(&source, b"admitted").unwrap();
1768        let digest = format!("sha256:{}", hex::encode(Sha256::digest(b"admitted")));
1769
1770        let handle = ArtifactHandle::open(&source, &digest, 8).unwrap();
1771        assert!(!handle.path().starts_with(&selected));
1772        fs::rename(&selected, workspace.path().join("replaced")).unwrap();
1773        fs::create_dir(&selected).unwrap();
1774        fs::write(selected.join("plugin"), b"attacker").unwrap();
1775
1776        assert_eq!(fs::read(handle.path()).unwrap(), b"admitted");
1777        assert_eq!(handle.read_verified().unwrap(), b"admitted");
1778    }
1779
1780    #[test]
1781    fn artifact_admission_streams_large_content_into_one_stable_snapshot() {
1782        let bytes = vec![0x5a; 4 * 1024 * 1024 + 17];
1783        let mut file = tempfile::NamedTempFile::new().unwrap();
1784        file.write_all(&bytes).unwrap();
1785        let digest = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
1786
1787        let handle = ArtifactHandle::open(file.path(), &digest, bytes.len() as u64).unwrap();
1788
1789        assert_eq!(handle.read_verified().unwrap(), bytes);
1790    }
1791
1792    /// Reproducible evidence command:
1793    /// `cargo test --release -p lenso-runtime-codec artifact_admission_streaming_benchmark -- --ignored --nocapture`
1794    #[test]
1795    #[ignore = "large Artifact admission benchmark; run explicitly"]
1796    fn artifact_admission_streaming_benchmark() {
1797        const BLOCK_BYTES: usize = 64 * 1024;
1798        let block = vec![0x5a; BLOCK_BYTES];
1799        for mebibytes in [4_usize, 64, 256] {
1800            let directory = tempfile::tempdir().unwrap();
1801            let path = directory.path().join("artifact");
1802            let mut source = fs::File::create(&path).unwrap();
1803            let mut hasher = Sha256::new();
1804            let blocks = mebibytes * 1024 * 1024 / BLOCK_BYTES;
1805            for _ in 0..blocks {
1806                source.write_all(&block).unwrap();
1807                hasher.update(&block);
1808            }
1809            drop(source);
1810            let size = u64::try_from(mebibytes * 1024 * 1024).unwrap();
1811            let digest = format!("sha256:{}", hex::encode(hasher.finalize()));
1812
1813            let started = std::time::Instant::now();
1814            let handle = ArtifactHandle::open(&path, &digest, size).unwrap();
1815            let elapsed = started.elapsed();
1816
1817            assert_eq!(handle.size(), size);
1818            println!(
1819                "{{\"mebibytes\":{mebibytes},\"elapsed_ms\":{:.3},\"mib_per_second\":{:.3}}}",
1820                elapsed.as_secs_f64() * 1_000.0,
1821                f64::from(u32::try_from(mebibytes).unwrap()) / elapsed.as_secs_f64()
1822            );
1823            drop(handle);
1824        }
1825    }
1826
1827    #[test]
1828    fn artifact_admission_honors_an_explicit_host_staging_root() {
1829        let source = tempfile::tempdir().unwrap();
1830        let staging = tempfile::tempdir().unwrap();
1831        let path = source.path().join("plugin");
1832        fs::write(&path, b"artifact").unwrap();
1833        let digest = format!("sha256:{}", hex::encode(Sha256::digest(b"artifact")));
1834
1835        let handle =
1836            ArtifactHandle::open_with_staging_root(&path, &digest, 8, staging.path()).unwrap();
1837
1838        assert_eq!(
1839            handle.path().parent().unwrap().parent().unwrap(),
1840            staging.path()
1841        );
1842    }
1843
1844    #[test]
1845    fn instance_resources_are_order_independent_and_immutable() {
1846        let left = InstanceResources::from_files([
1847            ("prompts/system.md".to_owned(), b"Build carefully.".to_vec()),
1848            ("rules.toml".to_owned(), b"turns = 4\n".to_vec()),
1849        ])
1850        .unwrap();
1851        let right = InstanceResources::from_files([
1852            ("rules.toml".to_owned(), b"turns = 4\n".to_vec()),
1853            ("prompts/system.md".to_owned(), b"Build carefully.".to_vec()),
1854        ])
1855        .unwrap();
1856
1857        assert_eq!(left.digest(), right.digest());
1858        assert_eq!(
1859            left.read_text("prompts/system.md").unwrap(),
1860            "Build carefully."
1861        );
1862        assert_eq!(left.file_count(), 2);
1863        assert_eq!(left.total_size(), 26);
1864    }
1865
1866    #[test]
1867    fn instance_resources_reject_escaping_and_duplicate_paths() {
1868        assert!(InstanceResources::from_files([("../secret".to_owned(), Vec::new())]).is_err());
1869        assert!(
1870            InstanceResources::from_files([
1871                ("rules.toml".to_owned(), Vec::new()),
1872                ("rules.toml".to_owned(), Vec::new()),
1873            ])
1874            .is_err()
1875        );
1876    }
1877}