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    /// Exact request Operation table.
458    fn request_operations(&self) -> &'static [&'static str];
459    /// Exact bidirectional stream Operation table.
460    fn stream_operations(&self) -> &'static [&'static str] {
461        &[]
462    }
463    /// Converts one generated request into validated portable JSON.
464    fn encode_request(&self, operation: &str, request: &dyn Any) -> Result<Value, RuntimeFailure>;
465    /// Converts portable JSON into the generated response value.
466    fn decode_response(
467        &self,
468        operation: &str,
469        value: Value,
470    ) -> Result<Box<dyn Any>, RuntimeFailure>;
471    /// Converts portable JSON into the generated Domain Error value.
472    fn decode_domain_error(
473        &self,
474        operation: &str,
475        value: Value,
476    ) -> Result<Box<dyn Any>, RuntimeFailure>;
477    /// Converts one generated stream-open request into validated portable JSON.
478    fn encode_stream_open(
479        &self,
480        operation: &str,
481        request: &dyn Any,
482    ) -> Result<Value, RuntimeFailure> {
483        let _ = request;
484        Err(unknown_operation(self.capability_id(), operation))
485    }
486    /// Converts one generated outbound stream message into validated portable JSON.
487    fn encode_stream_message(
488        &self,
489        operation: &str,
490        message: &dyn Any,
491    ) -> Result<Value, RuntimeFailure> {
492        let _ = message;
493        Err(unknown_operation(self.capability_id(), operation))
494    }
495    /// Converts one portable JSON stream message into its generated value.
496    fn decode_stream_message(
497        &self,
498        operation: &str,
499        value: Value,
500    ) -> Result<Box<dyn Any>, RuntimeFailure> {
501        let _ = value;
502        Err(unknown_operation(self.capability_id(), operation))
503    }
504    /// Converts one portable JSON stream terminal error into its generated value.
505    fn decode_stream_domain_error(
506        &self,
507        operation: &str,
508        value: Value,
509    ) -> Result<Box<dyn Any>, RuntimeFailure> {
510        let _ = value;
511        Err(unknown_operation(self.capability_id(), operation))
512    }
513    /// Invokes one exact Plan-bound host Request dependency from portable JSON.
514    fn invoke_host_request(
515        &self,
516        dependency: PluginDependencyHandle,
517        operation: String,
518        request: Value,
519        context: InvocationContext,
520    ) -> JsonHostRequestFuture {
521        let _ = (dependency, request, context);
522        Box::pin(futures::future::ready(Err(unknown_operation(
523            self.capability_id(),
524            &operation,
525        ))))
526    }
527    /// Opens one exact Plan-bound host Stream dependency from portable JSON.
528    fn open_host_stream(
529        &self,
530        dependency: PluginStreamDependencyHandle,
531        operation: String,
532        request: Value,
533        context: InvocationContext,
534    ) -> JsonHostStreamOpenFuture {
535        let _ = (dependency, request, context);
536        Box::pin(futures::future::ready(Err(unknown_operation(
537            self.capability_id(),
538            &operation,
539        ))))
540    }
541}
542
543/// Exact host outcome returned by a byte-oriented Plugin invocation.
544#[derive(Debug)]
545pub enum JsonInvocationOutcome {
546    /// Successful generated response value.
547    Success(Value),
548    /// Declared generated Domain Error value.
549    DomainError(Value),
550}
551
552/// Projects a Runtime Failure into a bounded, secret-free guest ABI value.
553pub fn json_runtime_failure(error: &RuntimeFailure) -> Value {
554    match error {
555        RuntimeFailure::Unavailable { capability } => serde_json::json!({
556            "kind": "unavailable",
557            "capability": capability,
558        }),
559        RuntimeFailure::UnknownOperation {
560            capability,
561            operation,
562        } => serde_json::json!({
563            "kind": "unknown_operation",
564            "capability": capability,
565            "operation": operation,
566        }),
567        RuntimeFailure::AmbiguousBinding {
568            capability,
569            providers,
570        } => serde_json::json!({
571            "kind": "ambiguous_binding",
572            "capability": capability,
573            "providers": providers,
574        }),
575        RuntimeFailure::ProtocolViolation { capability } => serde_json::json!({
576            "kind": "protocol_violation",
577            "capability": capability,
578        }),
579        RuntimeFailure::AdmissionClosed => serde_json::json!({ "kind": "admission_closed" }),
580        RuntimeFailure::ResourceExhausted {
581            capability,
582            operation,
583        } => serde_json::json!({
584            "kind": "resource_exhausted",
585            "capability": capability,
586            "operation": operation,
587        }),
588        RuntimeFailure::DeadlineExceeded { request_id } => serde_json::json!({
589            "kind": "deadline_exceeded",
590            "request_id": request_id.to_string(),
591        }),
592        RuntimeFailure::Cancelled { request_id } => serde_json::json!({
593            "kind": "cancelled",
594            "request_id": request_id.to_string(),
595        }),
596        RuntimeFailure::MissingPluginFactory { .. }
597        | RuntimeFailure::UnavailableExecutionClass { .. }
598        | RuntimeFailure::InvalidResolvedPlan { .. }
599        | RuntimeFailure::Internal { .. }
600        | RuntimeFailure::PluginFailure { .. }
601        | RuntimeFailure::PluginRestartExhausted { .. } => {
602            serde_json::json!({ "kind": "internal" })
603        }
604    }
605}
606
607/// Encodes a host import Request result into the stable guest envelope.
608pub fn json_host_invocation_envelope(
609    outcome: Result<JsonInvocationOutcome, RuntimeFailure>,
610) -> Value {
611    match outcome {
612        Ok(JsonInvocationOutcome::Success(value)) => serde_json::json!({ "ok": value }),
613        Ok(JsonInvocationOutcome::DomainError(value)) => serde_json::json!({ "error": value }),
614        Err(error) => serde_json::json!({ "runtime": json_runtime_failure(&error) }),
615    }
616}
617
618/// Result of one Plan-bound host Request import after generated value translation.
619pub type JsonHostRequestFuture =
620    futures::future::LocalBoxFuture<'static, Result<JsonInvocationOutcome, RuntimeFailure>>;
621
622/// Adapter-neutral host Stream session exposed to a byte-oriented guest.
623pub trait JsonHostStreamSession: std::fmt::Debug + 'static {
624    fn send(
625        self: Rc<Self>,
626        message: Value,
627    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>>;
628    fn receive(
629        self: Rc<Self>,
630    ) -> futures::future::LocalBoxFuture<'static, Result<JsonStreamItem, RuntimeFailure>>;
631    fn close_send(
632        self: Rc<Self>,
633    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>>;
634    fn cancel(&self);
635}
636
637/// Result of opening one Plan-bound host Stream import.
638pub type JsonHostStreamOpenFuture = futures::future::LocalBoxFuture<
639    'static,
640    Result<Result<Rc<dyn JsonHostStreamSession>, Value>, RuntimeFailure>,
641>;
642
643type DecodeStreamMessage<C> =
644    Rc<dyn Fn(Value) -> Result<<C as StreamCapability>::Message, RuntimeFailure>>;
645type EncodeStreamMessage<C> =
646    Rc<dyn Fn(<C as StreamCapability>::Message) -> Result<Value, RuntimeFailure>>;
647type EncodeStreamError<C> =
648    Rc<dyn Fn(<C as StreamCapability>::DomainError) -> Result<Value, RuntimeFailure>>;
649
650/// Wraps one generated typed host Stream as portable JSON for a guest import.
651pub fn json_host_stream<C: StreamCapability>(
652    stream: NativeStream<C>,
653    decode_message: impl Fn(Value) -> Result<C::Message, RuntimeFailure> + 'static,
654    encode_message: impl Fn(C::Message) -> Result<Value, RuntimeFailure> + 'static,
655    encode_error: impl Fn(C::DomainError) -> Result<Value, RuntimeFailure> + 'static,
656) -> Rc<dyn JsonHostStreamSession> {
657    Rc::new(TypedJsonHostStream {
658        stream: Rc::new(stream),
659        decode_message: Rc::new(decode_message),
660        encode_message: Rc::new(encode_message),
661        encode_error: Rc::new(encode_error),
662    })
663}
664
665struct TypedJsonHostStream<C: StreamCapability> {
666    stream: Rc<NativeStream<C>>,
667    decode_message: DecodeStreamMessage<C>,
668    encode_message: EncodeStreamMessage<C>,
669    encode_error: EncodeStreamError<C>,
670}
671
672impl<C: StreamCapability> std::fmt::Debug for TypedJsonHostStream<C> {
673    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
674        formatter
675            .debug_struct("TypedJsonHostStream")
676            .field("capability", &C::ID)
677            .finish_non_exhaustive()
678    }
679}
680
681impl<C: StreamCapability> JsonHostStreamSession for TypedJsonHostStream<C> {
682    fn send(
683        self: Rc<Self>,
684        message: Value,
685    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
686        Box::pin(async move {
687            let message = (self.decode_message)(message)?;
688            self.stream.send(message).await
689        })
690    }
691
692    fn receive(
693        self: Rc<Self>,
694    ) -> futures::future::LocalBoxFuture<'static, Result<JsonStreamItem, RuntimeFailure>> {
695        Box::pin(async move {
696            match self.stream.receive().await? {
697                StreamEvent::Message(message) => {
698                    (self.encode_message)(message).map(JsonStreamItem::Message)
699                }
700                StreamEvent::PeerHalfClosed => Ok(JsonStreamItem::PeerHalfClosed),
701                StreamEvent::Terminal(Ok(())) => Ok(JsonStreamItem::Terminal(Ok(()))),
702                StreamEvent::Terminal(Err(error)) => {
703                    (self.encode_error)(error).map(|error| JsonStreamItem::Terminal(Err(error)))
704                }
705            }
706        })
707    }
708
709    fn close_send(
710        self: Rc<Self>,
711    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
712        Box::pin(async move { self.stream.close_send().await })
713    }
714
715    fn cancel(&self) {
716        self.stream.cancel();
717    }
718}
719
720/// Stable request-only guest ABI implemented by byte-oriented Plugin runtimes.
721pub const JSON_REQUEST_ABI_V1: &str = "lenso.json-request@1";
722
723/// Stable Request and bidirectional Stream guest ABI.
724pub const JSON_INTERACTIONS_ABI_V1: &str = "lenso.json-interactions@1";
725
726/// Stable Request, Stream, and Plan-bound host Capability import ABI.
727pub const JSON_HOST_IMPORTS_ABI_V1: &str = "lenso.json-host-imports@1";
728/// Stable Request, Stream, and named Plan-bound Host Capability import ABI.
729pub const JSON_HOST_IMPORTS_ABI_V2: &str = "lenso.json-host-imports@2";
730
731/// Exact guest declaration returned before an Adapter opens readiness.
732#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
733#[serde(deny_unknown_fields)]
734pub struct JsonPluginDescriptor {
735    pub abi: String,
736    pub capabilities: Vec<JsonCapabilityDescriptor>,
737    #[serde(default, skip_serializing_if = "Vec::is_empty")]
738    pub required_capabilities: Vec<JsonRequiredCapabilityDescriptor>,
739}
740
741/// One exact request Capability exposed by a guest Plugin.
742#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
743#[serde(deny_unknown_fields)]
744pub struct JsonCapabilityDescriptor {
745    pub capability_id: String,
746    pub descriptor_version: String,
747    pub request_operations: Vec<String>,
748    #[serde(default, skip_serializing_if = "Vec::is_empty")]
749    pub stream_operations: Vec<String>,
750}
751
752/// One exact Capability requirement declared by a guest Plugin.
753#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
754#[serde(deny_unknown_fields)]
755pub struct JsonRequiredCapabilityDescriptor {
756    pub requirement_id: String,
757    pub capability_id: String,
758    pub descriptor_version: String,
759    pub cardinality: CapabilityCardinality,
760}
761
762/// Derives the only guest declaration accepted for one resolved Instance.
763pub fn expected_json_plugin_descriptor(
764    instance: &PluginInstancePlan,
765) -> Result<JsonPluginDescriptor, RuntimeFailure> {
766    let mut capabilities = Vec::with_capacity(instance.provided_capabilities().len());
767    for descriptor in instance.provided_capabilities() {
768        if !descriptor.event_operations().is_empty() {
769            return Err(RuntimeFailure::InvalidResolvedPlan {
770                detail: format!(
771                    "Execution class `{}` does not support Event endpoints",
772                    instance.execution_class()
773                ),
774            });
775        }
776        capabilities.push(JsonCapabilityDescriptor {
777            capability_id: descriptor.capability_id().to_owned(),
778            descriptor_version: descriptor.descriptor_version().to_owned(),
779            request_operations: descriptor
780                .request_operations()
781                .into_iter()
782                .map(str::to_owned)
783                .collect(),
784            stream_operations: descriptor
785                .stream_operations()
786                .into_iter()
787                .map(str::to_owned)
788                .collect(),
789        });
790    }
791    capabilities.sort();
792    if capabilities
793        .windows(2)
794        .any(|pair| pair[0].capability_id == pair[1].capability_id)
795    {
796        return Err(RuntimeFailure::InvalidResolvedPlan {
797            detail: format!(
798                "Instance `{}` declares a duplicate Capability",
799                instance.instance_key()
800            ),
801        });
802    }
803    let mut required_capabilities = instance
804        .required_capabilities()
805        .iter()
806        .map(|requirement| JsonRequiredCapabilityDescriptor {
807            requirement_id: requirement.requirement_id().to_owned(),
808            capability_id: requirement.capability_id().to_owned(),
809            descriptor_version: requirement.descriptor_version().to_owned(),
810            cardinality: requirement.cardinality(),
811        })
812        .collect::<Vec<_>>();
813    sort_required_capabilities(&mut required_capabilities);
814    Ok(JsonPluginDescriptor {
815        abi: if !required_capabilities.is_empty() {
816            JSON_HOST_IMPORTS_ABI_V2
817        } else if capabilities
818            .iter()
819            .any(|capability| !capability.stream_operations.is_empty())
820        {
821            JSON_INTERACTIONS_ABI_V1
822        } else {
823            JSON_REQUEST_ABI_V1
824        }
825        .to_owned(),
826        capabilities,
827        required_capabilities,
828    })
829}
830
831/// Parses and compares a guest Ready declaration with exact Plan authority.
832pub fn validate_json_plugin_descriptor(
833    instance: &PluginInstancePlan,
834    encoded: &str,
835) -> Result<(), RuntimeFailure> {
836    let mut actual = serde_json::from_str::<JsonPluginDescriptor>(encoded).map_err(|_| {
837        RuntimeFailure::ProtocolViolation {
838            capability: "lenso.json-request@1",
839        }
840    })?;
841    actual.capabilities.sort();
842    sort_required_capabilities(&mut actual.required_capabilities);
843    let expected = expected_json_plugin_descriptor(instance)?;
844    if actual != expected {
845        return Err(RuntimeFailure::InvalidResolvedPlan {
846            detail: format!(
847                "guest descriptor does not match resolved Instance `{}`",
848                instance.instance_key()
849            ),
850        });
851    }
852    Ok(())
853}
854
855fn sort_required_capabilities(requirements: &mut [JsonRequiredCapabilityDescriptor]) {
856    requirements.sort_by(|left, right| {
857        (
858            &left.requirement_id,
859            &left.capability_id,
860            &left.descriptor_version,
861            cardinality_order(left.cardinality),
862        )
863            .cmp(&(
864                &right.requirement_id,
865                &right.capability_id,
866                &right.descriptor_version,
867                cardinality_order(right.cardinality),
868            ))
869    });
870}
871
872const fn cardinality_order(cardinality: CapabilityCardinality) -> u8 {
873    match cardinality {
874        CapabilityCardinality::One => 0,
875        CapabilityCardinality::Optional => 1,
876        CapabilityCardinality::Many => 2,
877    }
878}
879
880/// Guest transport seam shared by Wasm Component and embedded-JavaScript Adapters.
881pub trait JsonRequestTransport: std::fmt::Debug + 'static {
882    fn invoke(
883        self: Rc<Self>,
884        capability: String,
885        operation: String,
886        request_json: String,
887        context: InvocationContext,
888    ) -> futures::future::LocalBoxFuture<'static, Result<JsonInvocationOutcome, RuntimeFailure>>;
889}
890
891/// One exact transport frame received from a byte-oriented guest stream.
892#[derive(Debug)]
893pub enum JsonStreamItem {
894    Message(Value),
895    PeerHalfClosed,
896    Terminal(Result<(), Value>),
897}
898
899/// Canonical portable JSON frame returned by `stream-receive` guest exports.
900#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
901#[serde(
902    tag = "kind",
903    content = "value",
904    rename_all = "kebab-case",
905    deny_unknown_fields
906)]
907pub enum JsonStreamFrame {
908    Message(Value),
909    PeerHalfClosed,
910    TerminalSuccess,
911    TerminalError(Value),
912}
913
914/// One exact Plan binding exposed to a guest Plugin after lifecycle activation.
915#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
916pub struct JsonHostBindingDescriptor {
917    pub binding_id: u32,
918    pub requirement_id: String,
919    pub provider_instance: String,
920    pub capability_id: String,
921    pub descriptor_version: String,
922    pub request_operations: Vec<String>,
923    pub stream_operations: Vec<String>,
924}
925
926#[derive(Clone)]
927struct JsonHostBinding {
928    descriptor: JsonHostBindingDescriptor,
929    codec: Rc<dyn JsonCapabilityCodec>,
930    request: Option<PluginDependencyHandle>,
931    stream: Option<PluginStreamDependencyHandle>,
932}
933
934impl std::fmt::Debug for JsonHostBinding {
935    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
936        formatter
937            .debug_struct("JsonHostBinding")
938            .field("descriptor", &self.descriptor)
939            .finish_non_exhaustive()
940    }
941}
942
943/// Activated, Plan-bound Capability imports for one byte-oriented guest generation.
944#[derive(Debug)]
945pub struct JsonHostImports {
946    codecs: BTreeMap<String, Rc<dyn JsonCapabilityCodec>>,
947    bindings: std::cell::RefCell<Option<Vec<JsonHostBinding>>>,
948    streams: std::cell::RefCell<BTreeMap<u64, Rc<dyn JsonHostStreamSession>>>,
949    next_stream_id: std::cell::Cell<u64>,
950    max_streams: usize,
951}
952
953impl JsonHostImports {
954    /// Creates a closed import table from the exact generated requirement codecs.
955    pub fn new(
956        codecs: Vec<Rc<dyn JsonCapabilityCodec>>,
957        max_streams: usize,
958    ) -> Result<Self, RuntimeFailure> {
959        let mut by_capability = BTreeMap::new();
960        for codec in codecs {
961            let capability = codec.capability_id().to_owned();
962            if let Some(existing) = by_capability.get(&capability) {
963                if !Rc::ptr_eq(existing, &codec) {
964                    return Err(RuntimeFailure::InvalidResolvedPlan {
965                        detail: format!(
966                            "conflicting guest import codecs for Capability `{capability}`"
967                        ),
968                    });
969                }
970            } else {
971                by_capability.insert(capability, codec);
972            }
973        }
974        Ok(Self {
975            codecs: by_capability,
976            bindings: std::cell::RefCell::new(None),
977            streams: std::cell::RefCell::new(BTreeMap::new()),
978            next_stream_id: std::cell::Cell::new(1),
979            max_streams,
980        })
981    }
982
983    /// Installs only the dependencies materialized from the immutable Plan.
984    pub fn activate(&self, dependencies: &PluginDependencies) -> Result<(), RuntimeFailure> {
985        if self.bindings.borrow().is_some() {
986            return Err(RuntimeFailure::Internal {
987                detail: "guest Capability imports were activated twice".to_owned(),
988            });
989        }
990        let mut bindings = Vec::with_capacity(dependencies.len());
991        for (index, dependency) in dependencies.bindings().iter().enumerate() {
992            let codec = self
993                .codecs
994                .get(dependency.capability_id())
995                .cloned()
996                .ok_or_else(|| RuntimeFailure::InvalidResolvedPlan {
997                    detail: format!(
998                        "no generated guest import codec for Capability `{}`",
999                        dependency.capability_id()
1000                    ),
1001                })?;
1002            let request = dependency.handle();
1003            let stream = dependency.stream_handle();
1004            validate_host_binding(&codec, request.as_ref(), stream.as_ref())?;
1005            let binding_id =
1006                u32::try_from(index).map_err(|_| RuntimeFailure::InvalidResolvedPlan {
1007                    detail: "guest import binding table exceeds u32 identity space".to_owned(),
1008                })?;
1009            bindings.push(JsonHostBinding {
1010                descriptor: JsonHostBindingDescriptor {
1011                    binding_id,
1012                    requirement_id: dependency.requirement_id().to_owned(),
1013                    provider_instance: dependency.provider_instance().to_owned(),
1014                    capability_id: dependency.capability_id().to_owned(),
1015                    descriptor_version: codec.descriptor_version().to_owned(),
1016                    request_operations: request.as_ref().map_or_else(Vec::new, |handle| {
1017                        handle
1018                            .operations()
1019                            .iter()
1020                            .map(|item| (*item).to_owned())
1021                            .collect()
1022                    }),
1023                    stream_operations: stream.as_ref().map_or_else(Vec::new, |handle| {
1024                        handle
1025                            .operations()
1026                            .iter()
1027                            .map(|item| (*item).to_owned())
1028                            .collect()
1029                    }),
1030                },
1031                codec,
1032                request,
1033                stream,
1034            });
1035        }
1036        self.bindings.replace(Some(bindings));
1037        Ok(())
1038    }
1039
1040    /// Returns the exact activated binding table in resolved provider order.
1041    pub fn descriptors(&self) -> Result<Vec<JsonHostBindingDescriptor>, RuntimeFailure> {
1042        self.bindings
1043            .borrow()
1044            .as_ref()
1045            .map(|bindings| {
1046                bindings
1047                    .iter()
1048                    .map(|binding| binding.descriptor.clone())
1049                    .collect()
1050            })
1051            .ok_or(RuntimeFailure::AdmissionClosed)
1052    }
1053
1054    /// Invokes one activated Request binding by its unforgeable table index.
1055    pub fn invoke(
1056        &self,
1057        binding_id: u32,
1058        operation: String,
1059        request: Value,
1060        context: InvocationContext,
1061    ) -> JsonHostRequestFuture {
1062        let binding = match self.binding(binding_id) {
1063            Ok(binding) => binding,
1064            Err(error) => return Box::pin(futures::future::ready(Err(error))),
1065        };
1066        let Some(dependency) = binding.request else {
1067            return Box::pin(futures::future::ready(Err(
1068                RuntimeFailure::UnknownOperation {
1069                    capability: binding.codec.capability_id(),
1070                    operation,
1071                },
1072            )));
1073        };
1074        binding
1075            .codec
1076            .invoke_host_request(dependency, operation, request, context)
1077    }
1078
1079    /// Opens one activated Stream binding and assigns an Adapter-local import id.
1080    pub fn open_stream(
1081        self: Rc<Self>,
1082        binding_id: u32,
1083        operation: String,
1084        request: Value,
1085        context: InvocationContext,
1086    ) -> futures::future::LocalBoxFuture<'static, Result<Result<u64, Value>, RuntimeFailure>> {
1087        Box::pin(async move {
1088            if self.streams.borrow().len() >= self.max_streams {
1089                return Err(RuntimeFailure::ResourceExhausted {
1090                    capability: JSON_HOST_IMPORTS_ABI_V2,
1091                    operation: "stream-open".to_owned(),
1092                });
1093            }
1094            let binding = self.binding(binding_id)?;
1095            let dependency = binding
1096                .stream
1097                .ok_or_else(|| RuntimeFailure::UnknownOperation {
1098                    capability: binding.codec.capability_id(),
1099                    operation: operation.clone(),
1100                })?;
1101            match binding
1102                .codec
1103                .open_host_stream(dependency, operation, request, context)
1104                .await?
1105            {
1106                Ok(stream) => {
1107                    let stream_id = self.next_stream_id.get();
1108                    let next =
1109                        stream_id
1110                            .checked_add(1)
1111                            .ok_or(RuntimeFailure::ResourceExhausted {
1112                                capability: JSON_HOST_IMPORTS_ABI_V2,
1113                                operation: "stream-open".to_owned(),
1114                            })?;
1115                    self.next_stream_id.set(next);
1116                    self.streams.borrow_mut().insert(stream_id, stream);
1117                    Ok(Ok(stream_id))
1118                }
1119                Err(error) => Ok(Err(error)),
1120            }
1121        })
1122    }
1123
1124    /// Sends one portable message through a guest-owned host Stream.
1125    pub fn send_stream(
1126        &self,
1127        stream_id: u64,
1128        message: Value,
1129    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
1130        match self.stream(stream_id) {
1131            Ok(stream) => stream.send(message),
1132            Err(error) => Box::pin(futures::future::ready(Err(error))),
1133        }
1134    }
1135
1136    /// Receives the next portable frame from one guest-owned host Stream.
1137    pub fn receive_stream(
1138        self: Rc<Self>,
1139        stream_id: u64,
1140    ) -> futures::future::LocalBoxFuture<'static, Result<JsonStreamItem, RuntimeFailure>> {
1141        Box::pin(async move {
1142            let stream = self.stream(stream_id)?;
1143            let item = stream.receive().await?;
1144            if matches!(item, JsonStreamItem::Terminal(_)) {
1145                self.streams.borrow_mut().remove(&stream_id);
1146            }
1147            Ok(item)
1148        })
1149    }
1150
1151    /// Half-closes the guest-to-host direction of one guest-owned host Stream.
1152    pub fn close_stream_send(
1153        &self,
1154        stream_id: u64,
1155    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
1156        match self.stream(stream_id) {
1157            Ok(stream) => stream.close_send(),
1158            Err(error) => Box::pin(futures::future::ready(Err(error))),
1159        }
1160    }
1161
1162    /// Cancels and removes one guest-owned host Stream.
1163    pub fn cancel_stream(&self, stream_id: u64) -> Result<(), RuntimeFailure> {
1164        let stream = self
1165            .streams
1166            .borrow_mut()
1167            .remove(&stream_id)
1168            .ok_or_else(unknown_host_stream)?;
1169        stream.cancel();
1170        Ok(())
1171    }
1172
1173    /// Closes admission and cancels every import Stream owned by this generation.
1174    pub fn deactivate(&self) {
1175        self.bindings.replace(None);
1176        for (_, stream) in std::mem::take(&mut *self.streams.borrow_mut()) {
1177            stream.cancel();
1178        }
1179    }
1180
1181    fn binding(&self, binding_id: u32) -> Result<JsonHostBinding, RuntimeFailure> {
1182        let bindings = self.bindings.borrow();
1183        let bindings = bindings.as_ref().ok_or(RuntimeFailure::AdmissionClosed)?;
1184        bindings
1185            .get(binding_id as usize)
1186            .cloned()
1187            .ok_or(RuntimeFailure::ProtocolViolation {
1188                capability: JSON_HOST_IMPORTS_ABI_V2,
1189            })
1190    }
1191
1192    fn stream(&self, stream_id: u64) -> Result<Rc<dyn JsonHostStreamSession>, RuntimeFailure> {
1193        self.streams
1194            .borrow()
1195            .get(&stream_id)
1196            .cloned()
1197            .ok_or_else(unknown_host_stream)
1198    }
1199}
1200
1201fn validate_host_binding(
1202    codec: &Rc<dyn JsonCapabilityCodec>,
1203    request: Option<&PluginDependencyHandle>,
1204    stream: Option<&PluginStreamDependencyHandle>,
1205) -> Result<(), RuntimeFailure> {
1206    for (capability, version) in request
1207        .map(|handle| (handle.capability_id(), handle.descriptor_version()))
1208        .into_iter()
1209        .chain(stream.map(|handle| (handle.capability_id(), handle.descriptor_version())))
1210    {
1211        if capability != codec.capability_id() || version != codec.descriptor_version() {
1212            return Err(RuntimeFailure::ProtocolViolation {
1213                capability: codec.capability_id(),
1214            });
1215        }
1216    }
1217    Ok(())
1218}
1219
1220fn unknown_host_stream() -> RuntimeFailure {
1221    RuntimeFailure::ProtocolViolation {
1222        capability: JSON_HOST_IMPORTS_ABI_V2,
1223    }
1224}
1225
1226impl JsonStreamFrame {
1227    /// Parses one bounded guest result into the Adapter-neutral transport item.
1228    pub fn decode(
1229        encoded: &str,
1230        capability: &'static str,
1231    ) -> Result<JsonStreamItem, RuntimeFailure> {
1232        match serde_json::from_str(encoded)
1233            .map_err(|_| RuntimeFailure::ProtocolViolation { capability })?
1234        {
1235            Self::Message(value) => Ok(JsonStreamItem::Message(value)),
1236            Self::PeerHalfClosed => Ok(JsonStreamItem::PeerHalfClosed),
1237            Self::TerminalSuccess => Ok(JsonStreamItem::Terminal(Ok(()))),
1238            Self::TerminalError(value) => Ok(JsonStreamItem::Terminal(Err(value))),
1239        }
1240    }
1241}
1242
1243/// Adapter-owned transport session for the portable JSON Stream ABI.
1244pub trait JsonStreamSessionTransport: std::fmt::Debug + 'static {
1245    fn send(
1246        self: Rc<Self>,
1247        message_json: String,
1248    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>>;
1249    fn receive(
1250        self: Rc<Self>,
1251    ) -> futures::future::LocalBoxFuture<'static, Result<JsonStreamItem, RuntimeFailure>>;
1252    fn close_send(
1253        self: Rc<Self>,
1254    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>>;
1255    fn cancel(&self);
1256}
1257
1258/// Adapter-owned result of opening one portable JSON stream transport session.
1259pub type JsonStreamOpenFuture = futures::future::LocalBoxFuture<
1260    'static,
1261    Result<Result<Rc<dyn JsonStreamSessionTransport>, Value>, RuntimeFailure>,
1262>;
1263
1264/// Guest transport seam shared by Stream-capable byte-oriented Adapters.
1265pub trait JsonStreamTransport: std::fmt::Debug + 'static {
1266    fn open(
1267        self: Rc<Self>,
1268        capability: String,
1269        operation: String,
1270        request_json: String,
1271        context: InvocationContext,
1272    ) -> JsonStreamOpenFuture;
1273}
1274
1275/// Builds typed Kernel endpoints over one exact guest transport generation.
1276pub fn json_request_endpoints<T: JsonRequestTransport>(
1277    transport: Rc<T>,
1278    codecs: Vec<Rc<dyn JsonCapabilityCodec>>,
1279) -> Vec<Rc<dyn NativeRequestEndpoint>> {
1280    let transport: Rc<dyn JsonRequestTransport> = transport;
1281    codecs
1282        .into_iter()
1283        .filter(|codec| !codec.request_operations().is_empty())
1284        .map(|codec| {
1285            Rc::new(JsonRequestEndpoint {
1286                transport: transport.clone(),
1287                codec,
1288            }) as Rc<dyn NativeRequestEndpoint>
1289        })
1290        .collect()
1291}
1292
1293/// Builds typed Kernel Stream endpoints over one exact guest transport generation.
1294pub fn json_stream_endpoints<T: JsonStreamTransport>(
1295    transport: Rc<T>,
1296    codecs: Vec<Rc<dyn JsonCapabilityCodec>>,
1297) -> Vec<Rc<dyn NativeStreamEndpoint>> {
1298    let transport: Rc<dyn JsonStreamTransport> = transport;
1299    codecs
1300        .into_iter()
1301        .filter(|codec| !codec.stream_operations().is_empty())
1302        .map(|codec| {
1303            Rc::new(JsonStreamEndpoint {
1304                transport: transport.clone(),
1305                codec,
1306            }) as Rc<dyn NativeStreamEndpoint>
1307        })
1308        .collect()
1309}
1310
1311#[derive(Debug)]
1312struct JsonStreamEndpoint {
1313    transport: Rc<dyn JsonStreamTransport>,
1314    codec: Rc<dyn JsonCapabilityCodec>,
1315}
1316
1317impl NativeStreamEndpoint for JsonStreamEndpoint {
1318    fn capability_id(&self) -> &'static str {
1319        self.codec.capability_id()
1320    }
1321    fn descriptor_version(&self) -> &'static str {
1322        self.codec.descriptor_version()
1323    }
1324    fn operations(&self) -> &'static [&'static str] {
1325        self.codec.stream_operations()
1326    }
1327
1328    fn open(
1329        &self,
1330        operation: &str,
1331        request: Box<dyn Any>,
1332        context: InvocationContext,
1333    ) -> futures::future::LocalBoxFuture<
1334        'static,
1335        Result<Result<Box<dyn NativeStreamSession>, Box<dyn Any>>, RuntimeFailure>,
1336    > {
1337        let transport = self.transport.clone();
1338        let codec = self.codec.clone();
1339        let operation = operation.to_owned();
1340        Box::pin(async move {
1341            if !codec.stream_operations().contains(&operation.as_str()) {
1342                return Err(unknown_operation(codec.capability_id(), &operation));
1343            }
1344            let request = codec.encode_stream_open(&operation, request.as_ref())?;
1345            let request_json =
1346                serde_json::to_string(&request).map_err(|_| RuntimeFailure::ProtocolViolation {
1347                    capability: codec.capability_id(),
1348                })?;
1349            match transport
1350                .open(
1351                    codec.capability_id().to_owned(),
1352                    operation.clone(),
1353                    request_json,
1354                    context,
1355                )
1356                .await?
1357            {
1358                Ok(session) => Ok(Ok(Box::new(JsonStreamSession {
1359                    session,
1360                    codec,
1361                    operation,
1362                }) as Box<dyn NativeStreamSession>)),
1363                Err(error) => codec.decode_stream_domain_error(&operation, error).map(Err),
1364            }
1365        })
1366    }
1367}
1368
1369#[derive(Debug)]
1370struct JsonStreamSession {
1371    session: Rc<dyn JsonStreamSessionTransport>,
1372    codec: Rc<dyn JsonCapabilityCodec>,
1373    operation: String,
1374}
1375
1376impl NativeStreamSession for JsonStreamSession {
1377    fn send(
1378        &self,
1379        message: Box<dyn Any>,
1380    ) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
1381        let encoded = self
1382            .codec
1383            .encode_stream_message(&self.operation, message.as_ref())
1384            .and_then(|value| {
1385                serde_json::to_string(&value).map_err(|_| RuntimeFailure::ProtocolViolation {
1386                    capability: self.codec.capability_id(),
1387                })
1388            });
1389        let session = self.session.clone();
1390        Box::pin(async move { session.send(encoded?).await })
1391    }
1392
1393    fn receive(
1394        &self,
1395    ) -> futures::future::LocalBoxFuture<'static, Result<NativeStreamItem, RuntimeFailure>> {
1396        let session = self.session.clone();
1397        let codec = self.codec.clone();
1398        let operation = self.operation.clone();
1399        Box::pin(async move {
1400            match session.receive().await? {
1401                JsonStreamItem::Message(value) => codec
1402                    .decode_stream_message(&operation, value)
1403                    .map(NativeStreamItem::Message),
1404                JsonStreamItem::PeerHalfClosed => Ok(NativeStreamItem::PeerHalfClosed),
1405                JsonStreamItem::Terminal(Ok(())) => Ok(NativeStreamItem::Terminal(Ok(()))),
1406                JsonStreamItem::Terminal(Err(value)) => codec
1407                    .decode_stream_domain_error(&operation, value)
1408                    .map(|error| NativeStreamItem::Terminal(Err(error))),
1409            }
1410        })
1411    }
1412
1413    fn close_send(&self) -> futures::future::LocalBoxFuture<'static, Result<(), RuntimeFailure>> {
1414        self.session.clone().close_send()
1415    }
1416
1417    fn cancel(&self) {
1418        self.session.cancel();
1419    }
1420}
1421
1422#[derive(Debug)]
1423struct JsonRequestEndpoint {
1424    transport: Rc<dyn JsonRequestTransport>,
1425    codec: Rc<dyn JsonCapabilityCodec>,
1426}
1427
1428impl NativeRequestEndpoint for JsonRequestEndpoint {
1429    fn capability_id(&self) -> &'static str {
1430        self.codec.capability_id()
1431    }
1432
1433    fn descriptor_version(&self) -> &'static str {
1434        self.codec.descriptor_version()
1435    }
1436
1437    fn operations(&self) -> &'static [&'static str] {
1438        self.codec.request_operations()
1439    }
1440
1441    fn invoke(
1442        &self,
1443        operation: &str,
1444        request: Box<dyn Any>,
1445        context: InvocationContext,
1446    ) -> futures::future::LocalBoxFuture<
1447        'static,
1448        Result<Result<Box<dyn Any>, Box<dyn Any>>, RuntimeFailure>,
1449    > {
1450        let transport = self.transport.clone();
1451        let codec = self.codec.clone();
1452        let operation = operation.to_owned();
1453        Box::pin(async move {
1454            if !codec.request_operations().contains(&operation.as_str()) {
1455                return Err(RuntimeFailure::UnknownOperation {
1456                    capability: codec.capability_id(),
1457                    operation,
1458                });
1459            }
1460            let request = codec.encode_request(&operation, request.as_ref())?;
1461            let request =
1462                serde_json::to_string(&request).map_err(|_| RuntimeFailure::ProtocolViolation {
1463                    capability: codec.capability_id(),
1464                })?;
1465            match transport
1466                .invoke(
1467                    codec.capability_id().to_owned(),
1468                    operation.clone(),
1469                    request,
1470                    context,
1471                )
1472                .await?
1473            {
1474                JsonInvocationOutcome::Success(value) => {
1475                    codec.decode_response(&operation, value).map(Ok)
1476                }
1477                JsonInvocationOutcome::DomainError(value) => {
1478                    codec.decode_domain_error(&operation, value).map(Err)
1479                }
1480            }
1481        })
1482    }
1483}
1484
1485/// Validates Plan descriptors against registered generated codecs.
1486pub fn codecs_for_instance(
1487    instance: &PluginInstancePlan,
1488    codecs: &BTreeMap<String, Rc<dyn JsonCapabilityCodec>>,
1489) -> Result<Vec<Rc<dyn JsonCapabilityCodec>>, RuntimeFailure> {
1490    let mut selected = Vec::with_capacity(instance.provided_capabilities().len());
1491    for descriptor in instance.provided_capabilities() {
1492        if !descriptor.event_operations().is_empty() {
1493            return Err(RuntimeFailure::InvalidResolvedPlan {
1494                detail: format!(
1495                    "Execution class `{}` does not support Event endpoints",
1496                    instance.execution_class()
1497                ),
1498            });
1499        }
1500        let codec = codecs.get(descriptor.capability_id()).ok_or_else(|| {
1501            RuntimeFailure::InvalidResolvedPlan {
1502                detail: format!(
1503                    "no generated codec for Capability `{}`",
1504                    descriptor.capability_id()
1505                ),
1506            }
1507        })?;
1508        let request_operations: Vec<_> = codec
1509            .request_operations()
1510            .iter()
1511            .map(|operation| (*operation).to_owned())
1512            .collect();
1513        let stream_operations: Vec<_> = codec
1514            .stream_operations()
1515            .iter()
1516            .map(|operation| (*operation).to_owned())
1517            .collect();
1518        let expected_request: Vec<_> = descriptor
1519            .request_operations()
1520            .into_iter()
1521            .map(str::to_owned)
1522            .collect();
1523        let expected_stream: Vec<_> = descriptor
1524            .stream_operations()
1525            .into_iter()
1526            .map(str::to_owned)
1527            .collect();
1528        if codec.descriptor_version() != descriptor.descriptor_version()
1529            || request_operations != expected_request
1530            || stream_operations != expected_stream
1531        {
1532            return Err(RuntimeFailure::ProtocolViolation {
1533                capability: codec.capability_id(),
1534            });
1535        }
1536        selected.push(codec.clone());
1537    }
1538    Ok(selected)
1539}
1540
1541/// Validates every declared guest requirement against one registered generated codec.
1542pub fn codecs_for_requirements(
1543    instance: &PluginInstancePlan,
1544    codecs: &BTreeMap<String, Rc<dyn JsonCapabilityCodec>>,
1545) -> Result<Vec<Rc<dyn JsonCapabilityCodec>>, RuntimeFailure> {
1546    let mut selected = Vec::with_capacity(instance.required_capabilities().len());
1547    for requirement in instance.required_capabilities() {
1548        let codec = codecs.get(requirement.capability_id()).ok_or_else(|| {
1549            RuntimeFailure::InvalidResolvedPlan {
1550                detail: format!(
1551                    "no generated guest import codec for Capability `{}`",
1552                    requirement.capability_id()
1553                ),
1554            }
1555        })?;
1556        if codec.descriptor_version() != requirement.descriptor_version() {
1557            return Err(RuntimeFailure::ProtocolViolation {
1558                capability: codec.capability_id(),
1559            });
1560        }
1561        selected.push(codec.clone());
1562    }
1563    Ok(selected)
1564}
1565
1566/// Builds exact request bindings from Adapter-prepared Plugin generations.
1567pub fn prepare_request_app(
1568    plan: &ResolvedAppPlan,
1569    execution_class: &ExecutionClassId,
1570    generations: BTreeMap<String, PreparedNativePlugin>,
1571) -> Result<PreparedNativeApp, RuntimeFailure> {
1572    let selected_instances = plan
1573        .plugin_instances()
1574        .iter()
1575        .filter(|instance| instance.execution_class() == execution_class)
1576        .map(|instance| instance.instance_key().to_owned())
1577        .collect::<std::collections::BTreeSet<_>>();
1578    let mut endpoints = BTreeMap::new();
1579    let mut stream_endpoints = BTreeMap::new();
1580    for (instance_key, generation) in &generations {
1581        for endpoint in generation.endpoints() {
1582            let identity = (instance_key.clone(), endpoint.capability_id().to_owned());
1583            if endpoints.insert(identity, endpoint.clone()).is_some() {
1584                return Err(RuntimeFailure::InvalidResolvedPlan {
1585                    detail: format!("duplicate request endpoint on Instance `{instance_key}`"),
1586                });
1587            }
1588        }
1589        for endpoint in generation.stream_endpoints() {
1590            let identity = (instance_key.clone(), endpoint.capability_id().to_owned());
1591            if stream_endpoints
1592                .insert(identity, endpoint.clone())
1593                .is_some()
1594            {
1595                return Err(RuntimeFailure::InvalidResolvedPlan {
1596                    detail: format!("duplicate stream endpoint on Instance `{instance_key}`"),
1597                });
1598            }
1599        }
1600    }
1601    for instance in plan
1602        .plugin_instances()
1603        .iter()
1604        .filter(|instance| selected_instances.contains(instance.instance_key()))
1605    {
1606        if !generations.contains_key(instance.instance_key()) {
1607            return Err(RuntimeFailure::InvalidResolvedPlan {
1608                detail: format!("Adapter omitted Instance `{}`", instance.instance_key()),
1609            });
1610        }
1611    }
1612    let mut bindings = Vec::new();
1613    let mut stream_bindings = Vec::new();
1614    for binding in plan.capability_bindings() {
1615        let key = (
1616            binding.provider_instance().to_owned(),
1617            binding.capability_id().to_owned(),
1618        );
1619        let request_endpoint = endpoints.get(&key);
1620        let stream_endpoint = stream_endpoints.get(&key);
1621        if let Some(endpoint) = request_endpoint {
1622            bindings.push(
1623                PreparedBinding::new(
1624                    binding.consumer_instance(),
1625                    binding.provider_instance(),
1626                    endpoint.clone(),
1627                )
1628                .with_requirement_id(binding.requirement_id()),
1629            );
1630        }
1631        if let Some(endpoint) = stream_endpoint {
1632            stream_bindings.push(
1633                PreparedStreamBinding::new(
1634                    binding.consumer_instance(),
1635                    binding.provider_instance(),
1636                    endpoint.clone(),
1637                )
1638                .with_requirement_id(binding.requirement_id()),
1639            );
1640        }
1641        if request_endpoint.is_none()
1642            && stream_endpoint.is_none()
1643            && selected_instances.contains(binding.provider_instance())
1644        {
1645            return Err(RuntimeFailure::InvalidResolvedPlan {
1646                detail: format!(
1647                    "Adapter omitted Capability `{}` endpoint for Instance `{}`",
1648                    binding.capability_id(),
1649                    binding.provider_instance()
1650                ),
1651            });
1652        }
1653    }
1654    Ok(PreparedNativeApp::new(bindings, generations).with_stream_bindings(stream_bindings))
1655}
1656
1657/// Looks up the exact codec and validates the Operation before dispatch.
1658pub fn require_operation(
1659    codecs: &BTreeMap<String, Rc<dyn JsonCapabilityCodec>>,
1660    capability_id: &str,
1661    operation: &str,
1662) -> Result<Rc<dyn JsonCapabilityCodec>, RuntimeFailure> {
1663    let codec =
1664        codecs
1665            .get(capability_id)
1666            .cloned()
1667            .ok_or_else(|| RuntimeFailure::InvalidResolvedPlan {
1668                detail: format!("no generated codec for Capability `{capability_id}`"),
1669            })?;
1670    if !codec.request_operations().contains(&operation) {
1671        return Err(RuntimeFailure::UnknownOperation {
1672            capability: codec.capability_id(),
1673            operation: operation.to_owned(),
1674        });
1675    }
1676    Ok(codec)
1677}
1678
1679fn unknown_operation(capability: &'static str, operation: &str) -> RuntimeFailure {
1680    RuntimeFailure::UnknownOperation {
1681        capability,
1682        operation: operation.to_owned(),
1683    }
1684}
1685
1686fn validate_digest(digest: &str) -> Result<(), RuntimeFailure> {
1687    let valid = digest.strip_prefix("sha256:").is_some_and(|hex| {
1688        hex.len() == 64
1689            && hex
1690                .bytes()
1691                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1692    });
1693    if valid {
1694        Ok(())
1695    } else {
1696        Err(RuntimeFailure::InvalidResolvedPlan {
1697            detail: format!("invalid canonical SHA-256 digest `{digest}`"),
1698        })
1699    }
1700}
1701
1702fn invalid_artifact(path: &Path, error: impl std::fmt::Display) -> RuntimeFailure {
1703    RuntimeFailure::InvalidResolvedPlan {
1704        detail: format!("cannot read Artifact `{}`: {error}", path.display()),
1705    }
1706}
1707
1708fn validate_resource_path(path: &str) -> Result<(), RuntimeFailure> {
1709    if path.is_empty()
1710        || path.starts_with('/')
1711        || path.contains(['\\', '\0'])
1712        || path
1713            .split('/')
1714            .any(|segment| segment.is_empty() || matches!(segment, "." | ".."))
1715    {
1716        return Err(invalid_resources(format!(
1717            "invalid Plugin resource path `{path}`"
1718        )));
1719    }
1720    Ok(())
1721}
1722
1723fn invalid_resources(detail: impl Into<String>) -> RuntimeFailure {
1724    RuntimeFailure::InvalidResolvedPlan {
1725        detail: detail.into(),
1726    }
1727}
1728
1729#[cfg(test)]
1730mod tests {
1731    use std::io::Write;
1732
1733    use super::*;
1734
1735    #[test]
1736    fn artifact_handle_keeps_the_admitted_bytes_after_source_drift() {
1737        let mut file = tempfile::NamedTempFile::new().unwrap();
1738        file.write_all(b"first").unwrap();
1739        let digest = format!("sha256:{}", hex::encode(Sha256::digest(b"first")));
1740        let handle = ArtifactHandle::open(file.path(), &digest, 5).unwrap();
1741        file.as_file_mut().set_len(0).unwrap();
1742        file.write_all(b"other").unwrap();
1743        assert_eq!(handle.read_verified().unwrap(), b"first");
1744        assert_ne!(handle.path(), file.path());
1745        assert_eq!(fs::read(handle.path()).unwrap(), b"first");
1746    }
1747
1748    #[test]
1749    fn artifact_snapshot_survives_source_parent_rename_and_replacement() {
1750        let workspace = tempfile::tempdir().unwrap();
1751        let selected = workspace.path().join("selected");
1752        fs::create_dir(&selected).unwrap();
1753        let source = selected.join("plugin");
1754        fs::write(&source, b"admitted").unwrap();
1755        let digest = format!("sha256:{}", hex::encode(Sha256::digest(b"admitted")));
1756
1757        let handle = ArtifactHandle::open(&source, &digest, 8).unwrap();
1758        assert!(!handle.path().starts_with(&selected));
1759        fs::rename(&selected, workspace.path().join("replaced")).unwrap();
1760        fs::create_dir(&selected).unwrap();
1761        fs::write(selected.join("plugin"), b"attacker").unwrap();
1762
1763        assert_eq!(fs::read(handle.path()).unwrap(), b"admitted");
1764        assert_eq!(handle.read_verified().unwrap(), b"admitted");
1765    }
1766
1767    #[test]
1768    fn artifact_admission_streams_large_content_into_one_stable_snapshot() {
1769        let bytes = vec![0x5a; 4 * 1024 * 1024 + 17];
1770        let mut file = tempfile::NamedTempFile::new().unwrap();
1771        file.write_all(&bytes).unwrap();
1772        let digest = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
1773
1774        let handle = ArtifactHandle::open(file.path(), &digest, bytes.len() as u64).unwrap();
1775
1776        assert_eq!(handle.read_verified().unwrap(), bytes);
1777    }
1778
1779    /// Reproducible evidence command:
1780    /// `cargo test --release -p lenso-runtime-codec artifact_admission_streaming_benchmark -- --ignored --nocapture`
1781    #[test]
1782    #[ignore = "large Artifact admission benchmark; run explicitly"]
1783    fn artifact_admission_streaming_benchmark() {
1784        const BLOCK_BYTES: usize = 64 * 1024;
1785        let block = vec![0x5a; BLOCK_BYTES];
1786        for mebibytes in [4_usize, 64, 256] {
1787            let directory = tempfile::tempdir().unwrap();
1788            let path = directory.path().join("artifact");
1789            let mut source = fs::File::create(&path).unwrap();
1790            let mut hasher = Sha256::new();
1791            let blocks = mebibytes * 1024 * 1024 / BLOCK_BYTES;
1792            for _ in 0..blocks {
1793                source.write_all(&block).unwrap();
1794                hasher.update(&block);
1795            }
1796            drop(source);
1797            let size = u64::try_from(mebibytes * 1024 * 1024).unwrap();
1798            let digest = format!("sha256:{}", hex::encode(hasher.finalize()));
1799
1800            let started = std::time::Instant::now();
1801            let handle = ArtifactHandle::open(&path, &digest, size).unwrap();
1802            let elapsed = started.elapsed();
1803
1804            assert_eq!(handle.size(), size);
1805            println!(
1806                "{{\"mebibytes\":{mebibytes},\"elapsed_ms\":{:.3},\"mib_per_second\":{:.3}}}",
1807                elapsed.as_secs_f64() * 1_000.0,
1808                f64::from(u32::try_from(mebibytes).unwrap()) / elapsed.as_secs_f64()
1809            );
1810            drop(handle);
1811        }
1812    }
1813
1814    #[test]
1815    fn artifact_admission_honors_an_explicit_host_staging_root() {
1816        let source = tempfile::tempdir().unwrap();
1817        let staging = tempfile::tempdir().unwrap();
1818        let path = source.path().join("plugin");
1819        fs::write(&path, b"artifact").unwrap();
1820        let digest = format!("sha256:{}", hex::encode(Sha256::digest(b"artifact")));
1821
1822        let handle =
1823            ArtifactHandle::open_with_staging_root(&path, &digest, 8, staging.path()).unwrap();
1824
1825        assert_eq!(
1826            handle.path().parent().unwrap().parent().unwrap(),
1827            staging.path()
1828        );
1829    }
1830
1831    #[test]
1832    fn instance_resources_are_order_independent_and_immutable() {
1833        let left = InstanceResources::from_files([
1834            ("prompts/system.md".to_owned(), b"Build carefully.".to_vec()),
1835            ("rules.toml".to_owned(), b"turns = 4\n".to_vec()),
1836        ])
1837        .unwrap();
1838        let right = InstanceResources::from_files([
1839            ("rules.toml".to_owned(), b"turns = 4\n".to_vec()),
1840            ("prompts/system.md".to_owned(), b"Build carefully.".to_vec()),
1841        ])
1842        .unwrap();
1843
1844        assert_eq!(left.digest(), right.digest());
1845        assert_eq!(
1846            left.read_text("prompts/system.md").unwrap(),
1847            "Build carefully."
1848        );
1849        assert_eq!(left.file_count(), 2);
1850        assert_eq!(left.total_size(), 26);
1851    }
1852
1853    #[test]
1854    fn instance_resources_reject_escaping_and_duplicate_paths() {
1855        assert!(InstanceResources::from_files([("../secret".to_owned(), Vec::new())]).is_err());
1856        assert!(
1857            InstanceResources::from_files([
1858                ("rules.toml".to_owned(), Vec::new()),
1859                ("rules.toml".to_owned(), Vec::new()),
1860            ])
1861            .is_err()
1862        );
1863    }
1864}