Skip to main content

rustev_contract/
plan_bundle.rs

1//! Plan bundle integrity documents (spec 042, 3.3). Signatures and
2//! registrations describe integrity, never execution authority.
3use crate::bounded::{ParseLimits, parse_bounded};
4use crate::canonical::{CanonicalError, tagged_digest};
5use crate::ids::{ContentDigest, ExecutionPolicyId, PlanId, ProfileId};
6use crate::scope::Handle;
7use crate::serve::{
8    BUNDLE_LIMITS, Embedded, List, Maybe, SERVICE_LIMITS, Text, Validate, canonical_envelope,
9    invalid, service_document,
10};
11use crate::{Document, DocumentError, Timestamp};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case", deny_unknown_fields)]
18pub enum SigningMode {
19    RequireSigned,
20    AllowUnsigned,
21    /// Admits unsigned bundles except those whose resolved profile chain,
22    /// leaf or base, carries a listed `ProfileId` (3.3.6, 3.3.8). The list
23    /// holds 1 to 1024 ids (an engineering bound; an empty list would be
24    /// `allow_unsigned` under another name).
25    DenyUnsignedForProfiles(List<ProfileId, 1, 1024>),
26}
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "snake_case", deny_unknown_fields)]
29pub enum Signer {
30    Unsigned,
31    Ed25519 {
32        key_id: Handle,
33    },
34    Sigstore {
35        certificate_identity: Text<1, 4096>,
36        issuer: Text<1, 4096>,
37    },
38}
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(transparent)]
41pub struct RegistrationId(pub ContentDigest);
42impl RegistrationId {
43    pub fn as_str(&self) -> &str {
44        self.0.as_str()
45    }
46}
47/// Successful output of the existing surface compile command. Output paths
48/// are report observations; they never become an endpoint in a binding.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
50#[serde(deny_unknown_fields)]
51pub struct CompileReport {
52    pub command: CompileCommand,
53    pub status: CompileStatus,
54    pub plan_id: PlanId,
55    pub out: Text<1, 4096>,
56}
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58pub enum CompileCommand {
59    #[serde(rename = "plan compile")]
60    Compile,
61}
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
63pub enum CompileStatus {
64    #[serde(rename = "compiled")]
65    Compiled,
66}
67/// A bundle's definition (3.3.1, 3.3.8): a definition document, or a spec
68/// 034 template, a `rustev.definition/2` document declaring `parameters` or
69/// `variants`. A concrete definition is checked by its own parser; a
70/// template is bounded here and checked by instantiation (034 3.9 stage 1),
71/// whose refusal is `compile_refused`, so anything else is refused here.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(transparent)]
74pub struct BundleDefinition(serde_json::Value);
75impl Document for BundleDefinition {
76    // The definition's own schema string: `Embedded` names it in the
77    // bundle's JSON Schema, which is unchanged.
78    const SCHEMA: &'static str = <crate::definition::Definition as Document>::SCHEMA;
79    const LIMITS: ParseLimits = crate::limits::DEFINITION_V1;
80    fn schema(&self) -> &str {
81        self.0["schema"].as_str().unwrap_or_default()
82    }
83    fn parse(bytes: &[u8]) -> Result<Self, DocumentError> {
84        if crate::definition::Definition::parse(bytes).is_ok() {
85            return Ok(Self(
86                parse_bounded(bytes, &Self::LIMITS).map_err(DocumentError::Parse)?,
87            ));
88        }
89        let value: serde_json::Value =
90            parse_bounded(bytes, &Self::LIMITS).map_err(DocumentError::Parse)?;
91        let template = value["schema"] == crate::schema::DEFINITION_V2
92            && (value.get("parameters").is_some() || value.get("variants").is_some());
93        if !template {
94            // The concrete parser's own refusal.
95            crate::definition::Definition::parse(bytes)?;
96        }
97        Ok(Self(value))
98    }
99}
100/// The variant a bundle is instantiated for (3.3.8): `{"named": <name>}`,
101/// the name matching `[a-z][a-z0-9_]{0,63}` (034 3.1.3). The variant `none`
102/// is spelled by omitting the member.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104#[serde(deny_unknown_fields)]
105pub struct BundleVariant {
106    #[schemars(regex(pattern = r"^[a-z][a-z0-9_]{0,63}$"))]
107    pub named: String,
108}
109impl BundleVariant {
110    fn valid(&self) -> bool {
111        let mut chars = self.named.chars();
112        self.named.len() <= 64
113            && chars.next().is_some_and(|c| c.is_ascii_lowercase())
114            && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
115    }
116}
117service_document!(PlanBundle,PlanBundleSchema,"rustev.plan-bundle/1",BUNDLE_LIMITS,{
118    pub definition:Embedded<BundleDefinition>,
119    pub execution_policy:Maybe<Embedded<crate::execution::ExecutionPolicy>>,
120    pub calibrations:List<Embedded<crate::calibration::CalibrationArtifact>,0,1024>,
121    /// The tenant profile, the leaf of the chain (spec 034); omitted for
122    /// none, so a bundle without one keeps its bytes (3.3.8).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub profile:Option<Embedded<crate::profile::Profile>>,
125    /// The variant the bundle is instantiated for; omitted for `none`.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub variant:Option<BundleVariant>,
128    /// 1 to 64 profiles available to the leaf's `extends` chain; omitted
129    /// for none, and only with a profile.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub profile_bases:Option<List<Embedded<crate::profile::Profile>,1,64>>,
132    #[schemars(extend("maxProperties" = 1024))] pub bindings:BTreeMap<Handle,Handle>,pub compile_report:CompileReport,
133});
134impl Validate for PlanBundle {
135    fn validate(&self) -> Result<(), String> {
136        if self.bindings.len() > 1024 {
137            return Err("at most 1024 backend bindings".into());
138        }
139        if self.variant.as_ref().is_some_and(|v| !v.valid()) {
140            return Err("variant names match [a-z][a-z0-9_]{0,63}".into());
141        }
142        if self.profile_bases.is_some() && self.profile.is_none() {
143            return Err("profile_bases requires a profile".into());
144        }
145        Ok(())
146    }
147}
148impl PlanBundle {
149    /// `tagged_digest` of the bundle's canonical bytes (042 3.3): exactly
150    /// the bytes `Document::canonical` produces, so a verifier that hashes
151    /// `canonical()` reproduces it.
152    pub fn digest(&self) -> Result<ContentDigest, CanonicalError> {
153        Ok(ContentDigest::from_digest(tagged_digest(
154            Self::SCHEMA,
155            &Document::canonical(self)?,
156        )))
157    }
158}
159/// Ed25519 signature octets, exactly 64. No encoding ambiguity and no key
160/// material is carried in a plan bundle.
161pub type Ed25519Signature = List<u8, 64, 64>;
162/// A Sigstore bundle is a third-party document: its bytes are bounded to
163/// 1 MiB, must parse as a JSON object, and are kept exactly as received so
164/// the verifier sees the signed bytes unchanged. This type makes no
165/// verification claim.
166#[derive(Debug, Clone, Serialize)]
167#[serde(transparent)]
168pub struct SigstoreBundle(Box<serde_json::value::RawValue>);
169impl PartialEq for SigstoreBundle {
170    fn eq(&self, other: &Self) -> bool {
171        self.0.get() == other.0.get()
172    }
173}
174impl SigstoreBundle {
175    /// The bundle's bytes exactly as received.
176    pub fn bytes(&self) -> &[u8] {
177        self.0.get().as_bytes()
178    }
179}
180impl JsonSchema for SigstoreBundle {
181    fn schema_name() -> std::borrow::Cow<'static, str> {
182        "SigstoreBundle".into()
183    }
184    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
185        schemars::json_schema!({"type":"object","maxProperties":10000,"description":"Opaque Sigstore JSON: at most 1 MiB, depth 32, strings 256 KiB, collections 10000, total values 100000, integer tokens 40 bytes."})
186    }
187}
188impl<'de> Deserialize<'de> for SigstoreBundle {
189    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
190        let raw = Box::<serde_json::value::RawValue>::deserialize(d)?;
191        let limits = ParseLimits {
192            max_bytes: 1024 * 1024,
193            max_depth: 32,
194            max_string_bytes: 256 * 1024,
195            max_collection_len: 10000,
196            max_total_values: 100000,
197            max_number_bytes: 40,
198            allow_fractional_numbers: false,
199        };
200        let value: serde_json::Value =
201            parse_bounded(raw.get().as_bytes(), &limits).map_err(serde::de::Error::custom)?;
202        if !value.is_object() {
203            return Err(serde::de::Error::custom(
204                "Sigstore bundle must be an object",
205            ));
206        }
207        Ok(Self(raw))
208    }
209}
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
211#[serde(rename_all = "snake_case", deny_unknown_fields)]
212pub enum SignatureScheme {
213    Ed25519 {
214        key_id: Handle,
215        signature: Ed25519Signature,
216    },
217    Sigstore {
218        bundle: SigstoreBundle,
219    },
220}
221service_document!(PlanBundleSignature,PlanBundleSignatureSchema,"rustev.plan-bundle-signature/1",SERVICE_LIMITS,{
222    pub bundle_digest:ContentDigest,pub scheme:SignatureScheme,
223});
224impl Validate for PlanBundleSignature {}
225impl PlanBundleSignature {
226    pub fn signing_input(&self) -> Vec<u8> {
227        format!("{}\n{}", Self::SCHEMA, self.bundle_digest).into_bytes()
228    }
229}
230service_document!(PlanRegistration,PlanRegistrationSchema,"rustev.plan-registration/1",SERVICE_LIMITS,{
231    pub plan_id:PlanId,pub execution_policy_id:Maybe<ExecutionPolicyId>,
232    /// The leaf profile's `ProfileId` (3.3.3, 3.3.8); omitted for none, so
233    /// a registration without a profile keeps its `RegistrationId`.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub profile_id:Option<ProfileId>,
236    pub bundle_digest:ContentDigest,pub signer:Signer,pub signing_mode:SigningMode,
237    pub service_build:Handle,pub configuration_digest:ContentDigest,pub registered_at_ms:Timestamp,
238});
239impl Validate for PlanRegistration {}
240impl PlanRegistration {
241    /// Registration time is an observation and does not affect identity.
242    pub fn registration_id(&self) -> Result<RegistrationId, CanonicalError> {
243        let mut value = serde_json::to_value(self).map_err(|e| CanonicalError::Serialize {
244            message: e.to_string(),
245        })?;
246        value.as_object_mut().unwrap().remove("registered_at_ms");
247        Ok(RegistrationId(ContentDigest::from_digest(tagged_digest(
248            Self::SCHEMA,
249            &crate::canonical::canonical_value_bytes(&value)?,
250        ))))
251    }
252}