Skip to main content

player_plugin/
plan.rs

1//! Immutable, canonical plugin resolution plans.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use thiserror::Error;
8
9use crate::{
10    PluginArtifactTransport, PluginCatalog, PluginCatalogError, PluginCatalogRecord,
11    PluginProvision, PluginRequirement, PluginResolution, PluginResolutionError, PluginResolver,
12    PluginResolverPolicy, validate_plugin_provisions, validate_plugin_requirements,
13};
14
15/// Version of the immutable plugin plan wire model.
16pub const PLUGIN_PLAN_SCHEMA_VERSION: u32 = 1;
17/// Maximum selected providers implied by the resolver's constraint budget.
18pub const MAX_PLUGIN_PLAN_PROVIDERS: usize = crate::MAX_PLUGIN_RESOLUTION_CONSTRAINTS;
19
20/// Immutable host policy snapshot bound into a plugin plan fingerprint.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22pub struct PluginPlanPolicy {
23    transport: PluginArtifactTransport,
24    target: String,
25    architecture: String,
26    abi_major: u16,
27    abi_minor: u16,
28    plugin_priorities: BTreeMap<String, i32>,
29}
30
31impl PluginPlanPolicy {
32    pub const fn transport(&self) -> PluginArtifactTransport {
33        self.transport
34    }
35
36    pub fn target(&self) -> &str {
37        &self.target
38    }
39
40    pub fn architecture(&self) -> &str {
41        &self.architecture
42    }
43
44    pub const fn abi_major(&self) -> u16 {
45        self.abi_major
46    }
47
48    pub const fn abi_minor(&self) -> u16 {
49        self.abi_minor
50    }
51
52    pub fn plugin_priorities(&self) -> &BTreeMap<String, i32> {
53        &self.plugin_priorities
54    }
55
56    fn from_resolver_policy(policy: &PluginResolverPolicy) -> Self {
57        Self {
58            transport: policy.transport(),
59            target: policy.target().to_owned(),
60            architecture: policy.architecture().to_owned(),
61            abi_major: policy.abi_major(),
62            abi_minor: policy.abi_minor(),
63            plugin_priorities: policy.plugin_priorities().clone(),
64        }
65    }
66
67    fn to_resolver_policy(&self) -> Result<PluginResolverPolicy, PluginPlanError> {
68        let mut policy = PluginResolverPolicy::new(
69            self.transport,
70            self.target.clone(),
71            self.architecture.clone(),
72            self.abi_major,
73            self.abi_minor,
74        )?;
75        for (plugin_id, priority) in &self.plugin_priorities {
76            policy.set_plugin_priority(plugin_id.clone(), *priority)?;
77        }
78        Ok(policy)
79    }
80}
81
82/// One service selection stored without a live artifact owner or session.
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
84pub struct PluginPlanProvider {
85    service: String,
86    provided_version: String,
87    artifact_identity: String,
88}
89
90impl PluginPlanProvider {
91    pub fn service(&self) -> &str {
92        &self.service
93    }
94
95    pub fn provided_version(&self) -> &str {
96        &self.provided_version
97    }
98
99    pub fn artifact_identity(&self) -> &str {
100        &self.artifact_identity
101    }
102}
103
104/// Canonical metadata-only result that can be inspected before runtime startup.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct PluginPlan {
107    schema_version: u32,
108    catalog_fingerprint: String,
109    catalog: Vec<PluginCatalogRecord>,
110    policy: PluginPlanPolicy,
111    requirements: Vec<PluginRequirement>,
112    providers: Vec<PluginPlanProvider>,
113    artifacts: Vec<PluginCatalogRecord>,
114    fingerprint: String,
115}
116
117impl PluginPlan {
118    pub const fn schema_version(&self) -> u32 {
119        self.schema_version
120    }
121
122    pub fn catalog_fingerprint(&self) -> &str {
123        &self.catalog_fingerprint
124    }
125
126    pub fn catalog(&self) -> &[PluginCatalogRecord] {
127        &self.catalog
128    }
129
130    pub fn policy(&self) -> &PluginPlanPolicy {
131        &self.policy
132    }
133
134    pub fn requirements(&self) -> &[PluginRequirement] {
135        &self.requirements
136    }
137
138    pub fn providers(&self) -> &[PluginPlanProvider] {
139        &self.providers
140    }
141
142    /// Returns selected artifacts in dependency-first execution order.
143    pub fn artifacts(&self) -> &[PluginCatalogRecord] {
144        &self.artifacts
145    }
146
147    pub fn fingerprint(&self) -> &str {
148        &self.fingerprint
149    }
150
151    /// Encodes the complete plan envelope in canonical JSON field and array order.
152    pub fn to_json(&self) -> Result<Vec<u8>, PluginPlanError> {
153        serde_json::to_vec(self).map_err(|error| PluginPlanError::Json {
154            message: error.to_string(),
155        })
156    }
157
158    /// Decodes a plan and rejects stale, tampered, or noncanonical projections.
159    pub fn from_json(bytes: &[u8]) -> Result<Self, PluginPlanError> {
160        let wire = serde_json::from_slice::<PluginPlanWire>(bytes).map_err(|error| {
161            PluginPlanError::Json {
162                message: error.to_string(),
163            }
164        })?;
165        Self::from_wire(wire)
166    }
167
168    pub(crate) fn from_resolution(
169        policy: &PluginResolverPolicy,
170        requirements: &[PluginRequirement],
171        catalog: &PluginCatalog,
172        resolution: PluginResolution,
173    ) -> Result<Self, PluginPlanError> {
174        let requirements = canonical_requirements(requirements)?;
175        let providers = providers_from_resolution(&resolution);
176        let mut plan = Self {
177            schema_version: PLUGIN_PLAN_SCHEMA_VERSION,
178            catalog_fingerprint: resolution.catalog_fingerprint().to_owned(),
179            catalog: catalog.records().to_vec(),
180            policy: PluginPlanPolicy::from_resolver_policy(policy),
181            requirements,
182            providers,
183            artifacts: resolution.artifacts().to_vec(),
184            fingerprint: String::new(),
185        };
186        plan.fingerprint = plan.compute_fingerprint()?;
187        Ok(plan)
188    }
189
190    fn from_wire(wire: PluginPlanWire) -> Result<Self, PluginPlanError> {
191        if wire.schema_version != PLUGIN_PLAN_SCHEMA_VERSION {
192            return Err(PluginPlanError::UnsupportedSchemaVersion {
193                expected: PLUGIN_PLAN_SCHEMA_VERSION,
194                actual: wire.schema_version,
195            });
196        }
197        if !is_lowercase_sha256(&wire.catalog_fingerprint) {
198            return Err(PluginPlanError::InvalidFingerprint {
199                field: "catalog_fingerprint".to_owned(),
200            });
201        }
202        if !is_lowercase_sha256(&wire.fingerprint) {
203            return Err(PluginPlanError::InvalidFingerprint {
204                field: "fingerprint".to_owned(),
205            });
206        }
207
208        let policy = PluginPlanPolicy {
209            transport: wire.policy.transport,
210            target: wire.policy.target,
211            architecture: wire.policy.architecture,
212            abi_major: wire.policy.abi_major,
213            abi_minor: wire.policy.abi_minor,
214            plugin_priorities: wire.policy.plugin_priorities,
215        };
216        policy.to_resolver_policy()?;
217
218        let requirements = canonical_requirements(&wire.requirements)?;
219        if requirements != wire.requirements {
220            return Err(PluginPlanError::NonCanonical {
221                field: "requirements".to_owned(),
222            });
223        }
224
225        let providers = wire
226            .providers
227            .into_iter()
228            .map(|provider| PluginPlanProvider {
229                service: provider.service,
230                provided_version: provider.provided_version,
231                artifact_identity: provider.artifact_identity,
232            })
233            .collect::<Vec<_>>();
234        validate_providers(&providers)?;
235        let mut canonical_providers = providers.clone();
236        canonical_providers.sort();
237        if canonical_providers != providers {
238            return Err(PluginPlanError::NonCanonical {
239                field: "providers".to_owned(),
240            });
241        }
242
243        let plan = Self {
244            schema_version: wire.schema_version,
245            catalog_fingerprint: wire.catalog_fingerprint,
246            catalog: wire.catalog,
247            policy,
248            requirements,
249            providers,
250            artifacts: wire.artifacts,
251            fingerprint: wire.fingerprint,
252        };
253        let canonical_catalog = PluginCatalog::from_records(plan.catalog.clone())?;
254        if canonical_catalog.records() != plan.catalog {
255            return Err(PluginPlanError::NonCanonical {
256                field: "catalog".to_owned(),
257            });
258        }
259        let actual_catalog_fingerprint = catalog_fingerprint_for_records(&plan.catalog)?;
260        if actual_catalog_fingerprint != plan.catalog_fingerprint {
261            return Err(PluginPlanError::CatalogFingerprintMismatch {
262                expected: actual_catalog_fingerprint,
263                actual: plan.catalog_fingerprint,
264            });
265        }
266        plan.validate_resolution_projection()?;
267        let expected = plan.compute_fingerprint()?;
268        if expected != plan.fingerprint {
269            return Err(PluginPlanError::FingerprintMismatch {
270                expected,
271                actual: plan.fingerprint,
272            });
273        }
274        Ok(plan)
275    }
276
277    fn validate_resolution_projection(&self) -> Result<(), PluginPlanError> {
278        let catalog = PluginCatalog::from_records(self.catalog.clone())?;
279        let policy = self.policy.to_resolver_policy()?;
280        let resolution = PluginResolver::new(&catalog, policy).resolve(&self.requirements)?;
281        if providers_from_resolution(&resolution) != self.providers {
282            return Err(PluginPlanError::ProjectionMismatch {
283                field: "providers".to_owned(),
284            });
285        }
286        if resolution.artifacts() != self.artifacts {
287            let mut expected = resolution.artifacts().to_vec();
288            let mut actual = self.artifacts.clone();
289            expected.sort_by_key(PluginCatalogRecord::canonical_identity_key);
290            actual.sort_by_key(PluginCatalogRecord::canonical_identity_key);
291            return Err(if expected == actual {
292                PluginPlanError::NonCanonical {
293                    field: "artifacts".to_owned(),
294                }
295            } else {
296                PluginPlanError::ProjectionMismatch {
297                    field: "artifacts".to_owned(),
298                }
299            });
300        }
301        Ok(())
302    }
303
304    fn compute_fingerprint(&self) -> Result<String, PluginPlanError> {
305        let payload = PluginPlanFingerprintPayload {
306            schema_version: self.schema_version,
307            catalog_fingerprint: &self.catalog_fingerprint,
308            catalog: &self.catalog,
309            policy: &self.policy,
310            requirements: &self.requirements,
311            providers: &self.providers,
312            artifacts: &self.artifacts,
313        };
314        let bytes = serde_json::to_vec(&payload).map_err(|error| PluginPlanError::Json {
315            message: error.to_string(),
316        })?;
317        Ok(hex::encode(Sha256::digest(bytes)))
318    }
319}
320
321#[derive(Debug, Error, Clone, PartialEq, Eq)]
322pub enum PluginPlanError {
323    #[error("failed to decode or encode plugin plan JSON: {message}")]
324    Json { message: String },
325    #[error("unsupported plugin plan schema version {actual}; expected {expected}")]
326    UnsupportedSchemaVersion { expected: u32, actual: u32 },
327    #[error("plugin plan field `{field}` is not canonical lowercase SHA-256")]
328    InvalidFingerprint { field: String },
329    #[error("plugin plan field `{field}` is not in canonical order")]
330    NonCanonical { field: String },
331    #[error("plugin plan field `{field}` does not match the resolver projection")]
332    ProjectionMismatch { field: String },
333    #[error("invalid plugin plan requirements: {message}")]
334    InvalidRequirements { message: String },
335    #[error("plugin plan contains more than {limit} selected providers")]
336    TooManyProviders { limit: usize },
337    #[error("plugin plan contains duplicate provider service `{service}`")]
338    DuplicateProvider { service: String },
339    #[error("invalid plugin plan provider `{service}`: {message}")]
340    InvalidProvider { service: String, message: String },
341    #[error("plugin plan fingerprint mismatch: expected {expected}, got {actual}")]
342    FingerprintMismatch { expected: String, actual: String },
343    #[error("plugin plan catalog fingerprint mismatch: expected {expected}, got {actual}")]
344    CatalogFingerprintMismatch { expected: String, actual: String },
345    #[error(transparent)]
346    Catalog(#[from] PluginCatalogError),
347    #[error(transparent)]
348    Resolution(#[from] PluginResolutionError),
349}
350
351#[derive(Debug, Deserialize)]
352#[serde(deny_unknown_fields)]
353struct PluginPlanWire {
354    schema_version: u32,
355    catalog_fingerprint: String,
356    catalog: Vec<PluginCatalogRecord>,
357    policy: PluginPlanPolicyWire,
358    requirements: Vec<PluginRequirement>,
359    providers: Vec<PluginPlanProviderWire>,
360    artifacts: Vec<PluginCatalogRecord>,
361    fingerprint: String,
362}
363
364#[derive(Debug, Deserialize)]
365#[serde(deny_unknown_fields)]
366struct PluginPlanPolicyWire {
367    transport: PluginArtifactTransport,
368    target: String,
369    architecture: String,
370    abi_major: u16,
371    abi_minor: u16,
372    plugin_priorities: BTreeMap<String, i32>,
373}
374
375#[derive(Debug, Deserialize)]
376#[serde(deny_unknown_fields)]
377struct PluginPlanProviderWire {
378    service: String,
379    provided_version: String,
380    artifact_identity: String,
381}
382
383#[derive(Serialize)]
384struct PluginPlanFingerprintPayload<'a> {
385    schema_version: u32,
386    catalog_fingerprint: &'a str,
387    catalog: &'a [PluginCatalogRecord],
388    policy: &'a PluginPlanPolicy,
389    requirements: &'a [PluginRequirement],
390    providers: &'a [PluginPlanProvider],
391    artifacts: &'a [PluginCatalogRecord],
392}
393
394fn canonical_requirements(
395    requirements: &[PluginRequirement],
396) -> Result<Vec<PluginRequirement>, PluginPlanError> {
397    validate_plugin_requirements(requirements).map_err(|error| {
398        PluginPlanError::InvalidRequirements {
399            message: error.to_string(),
400        }
401    })?;
402    let mut requirements = requirements.to_vec();
403    requirements.sort();
404    Ok(requirements)
405}
406
407fn providers_from_resolution(resolution: &PluginResolution) -> Vec<PluginPlanProvider> {
408    resolution
409        .providers()
410        .iter()
411        .map(|provider| PluginPlanProvider {
412            service: provider.service().to_owned(),
413            provided_version: provider.provided_version().to_owned(),
414            artifact_identity: provider.artifact().canonical_identity_key(),
415        })
416        .collect()
417}
418
419fn catalog_fingerprint_for_records(
420    records: &[PluginCatalogRecord],
421) -> Result<String, PluginPlanError> {
422    Ok(PluginCatalog::from_records(records.to_vec())?
423        .fingerprint()
424        .to_owned())
425}
426
427fn validate_providers(providers: &[PluginPlanProvider]) -> Result<(), PluginPlanError> {
428    if providers.len() > MAX_PLUGIN_PLAN_PROVIDERS {
429        return Err(PluginPlanError::TooManyProviders {
430            limit: MAX_PLUGIN_PLAN_PROVIDERS,
431        });
432    }
433    let mut services = BTreeSet::new();
434    for provider in providers {
435        let provision = PluginProvision {
436            service: provider.service.clone(),
437            version: provider.provided_version.clone(),
438        };
439        validate_plugin_provisions(&[provision]).map_err(|error| {
440            PluginPlanError::InvalidProvider {
441                service: provider.service.clone(),
442                message: error.to_string(),
443            }
444        })?;
445        if !services.insert(provider.service.clone()) {
446            return Err(PluginPlanError::DuplicateProvider {
447                service: provider.service.clone(),
448            });
449        }
450        if provider.artifact_identity.is_empty() {
451            return Err(PluginPlanError::InvalidProvider {
452                service: provider.service.clone(),
453                message: "artifact identity must not be empty".to_owned(),
454            });
455        }
456    }
457    Ok(())
458}
459
460fn is_lowercase_sha256(value: &str) -> bool {
461    value.len() == 64
462        && value
463            .bytes()
464            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
465}