Skip to main content

arete_hash/protocol/
release.rs

1//! Release-side contracts: the generated stack source, the release, its
2//! review signature, the build receipt and published resources.
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use super::error::{ProtocolContractError, ProtocolResult};
8use super::shape::*;
9use crate::{
10    hash_jcs, Compiler, HashError, HashId, ProgramRelease, ProgramSpec, ProtocolHandlerBundle,
11    ProtocolRelease, ProtocolReleaseReview, ProtocolResource, ProtocolSpec, ProtocolStackSource,
12    SdkExtension, StackManifest,
13};
14
15pub const PROTOCOL_STACK_SOURCE_SCHEMA_V1: &str = "arete.protocol-stack-source/v1";
16pub const PROTOCOL_RELEASE_SCHEMA_V1: &str = "arete.protocol-release/v1";
17pub const PROTOCOL_RELEASE_REVIEW_SCHEMA_V1: &str = "arete.protocol-release-review/v1";
18pub const PROTOCOL_BUILD_RECEIPT_SCHEMA_V1: &str = "arete.protocol-build-receipt/v1";
19pub const PROTOCOL_RESOURCE_SCHEMA_V1: &str = "arete.protocol-resource/v1";
20pub const STACK_SOURCE_SCHEMA_V1: &str = "arete.stack-source/v1";
21
22const MAX_DEPENDENCIES: usize = 64;
23
24fn model<T: serde::de::DeserializeOwned>(value: &Value) -> ProtocolResult<T> {
25    serde_json::from_value(value.clone()).map_err(|error| {
26        ProtocolContractError::new("invalid-value", "", format!("model mismatch: {error}"))
27    })
28}
29
30// ---------------------------------------------------------------------------
31// Stack source
32// ---------------------------------------------------------------------------
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct ProtocolStackSourceV1 {
37    pub schema: String,
38    pub generator: GeneratorV1,
39    pub profile: StackProfile,
40    /// The generated `arete.stack-source/v1` document, which a stack
41    /// compiler takes unchanged.
42    pub stack_source: Value,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase", deny_unknown_fields)]
47pub struct GeneratorV1 {
48    pub name: String,
49    pub version: String,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub enum StackProfile {
55    /// Reads only the underlying programs: no memo decoding or `via` labels.
56    Interim,
57    /// Memo-decoding, attestation-gated, scoped rows.
58    Full,
59}
60
61impl ProtocolStackSourceV1 {
62    pub fn hash(&self) -> Result<HashId<ProtocolStackSource>, HashError> {
63        hash_jcs::<ProtocolStackSource, _>(self)
64    }
65}
66
67pub fn validate_protocol_stack_source_value(
68    value: &Value,
69) -> ProtocolResult<ProtocolStackSourceV1> {
70    reject_floats(value, "")?;
71    let root = Obj::new(
72        value,
73        "",
74        &["schema", "generator", "profile", "stackSource"],
75    )?;
76    schema_tag(
77        root.req("schema")?,
78        "/schema",
79        PROTOCOL_STACK_SOURCE_SCHEMA_V1,
80    )?;
81    shape_generator(root.req("generator")?, "/generator")?;
82    one_of(root.req("profile")?, "/profile", &["interim", "full"])?;
83    let source = root.req("stackSource")?;
84    let source_obj = source
85        .as_object()
86        .ok_or_else(|| err("invalid-type", "/stackSource", "expected an object"))?;
87    match source_obj.get("schema") {
88        Some(tag) => schema_tag(tag, "/stackSource/schema", STACK_SOURCE_SCHEMA_V1)?,
89        None => {
90            return Err(err(
91                "missing-field",
92                "/stackSource/schema",
93                "missing field 'schema'",
94            ))
95        }
96    }
97    model(value)
98}
99
100fn shape_generator(value: &Value, path: &str) -> ProtocolResult<()> {
101    let generator = Obj::new(value, path, &["name", "version"])?;
102    let name = bounded_str(generator.req("name")?, &generator.path_of("name"), 128)?;
103    if name.is_empty() {
104        return Err(err(
105            "invalid-value",
106            &generator.path_of("name"),
107            "name must not be empty",
108        ));
109    }
110    semver(generator.req("version")?, &generator.path_of("version"))?;
111    Ok(())
112}
113
114// ---------------------------------------------------------------------------
115// Release
116// ---------------------------------------------------------------------------
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase", deny_unknown_fields)]
120pub struct ProtocolReleaseV1 {
121    pub schema: String,
122    pub protocol: String,
123    pub version: String,
124    pub spec_hash: HashId<ProtocolSpec>,
125    /// `null` for template-only protocols.
126    pub handler_bundle_hash: Option<HashId<ProtocolHandlerBundle>>,
127    pub stack_source_hash: HashId<ProtocolStackSource>,
128    pub compiler_hash: HashId<Compiler>,
129    pub dependencies: ReleaseDependenciesV1,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase", deny_unknown_fields)]
134pub struct ReleaseDependenciesV1 {
135    pub programs: Vec<ProgramDependencyV1>,
136    pub stacks: Vec<StackDependencyV1>,
137    pub sdk_extensions: Vec<SdkExtensionDependencyV1>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase", deny_unknown_fields)]
142pub struct ProgramDependencyV1 {
143    pub alias: String,
144    pub program_id: String,
145    pub program_spec_hash: HashId<ProgramSpec>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub program_release_hash: Option<HashId<ProgramRelease>>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase", deny_unknown_fields)]
152pub struct StackDependencyV1 {
153    pub alias: String,
154    pub stack_manifest_hash: HashId<StackManifest>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase", deny_unknown_fields)]
159pub struct SdkExtensionDependencyV1 {
160    pub alias: String,
161    pub sdk_extension_hash: HashId<SdkExtension>,
162}
163
164impl ProtocolReleaseV1 {
165    pub fn hash(&self) -> Result<HashId<ProtocolRelease>, HashError> {
166        hash_jcs::<ProtocolRelease, _>(self)
167    }
168}
169
170pub fn validate_protocol_release_value(value: &Value) -> ProtocolResult<ProtocolReleaseV1> {
171    reject_floats(value, "")?;
172    let root = Obj::new(
173        value,
174        "",
175        &[
176            "schema",
177            "protocol",
178            "version",
179            "specHash",
180            "handlerBundleHash",
181            "stackSourceHash",
182            "compilerHash",
183            "dependencies",
184        ],
185    )?;
186    schema_tag(root.req("schema")?, "/schema", PROTOCOL_RELEASE_SCHEMA_V1)?;
187    protocol_name(as_str(root.req("protocol")?, "/protocol")?, "/protocol")?;
188    semver(root.req("version")?, "/version")?;
189    hash_id::<ProtocolSpec>(root.req("specHash")?, "/specHash")?;
190    if let Some(value) = root.nullable("handlerBundleHash")? {
191        hash_id::<ProtocolHandlerBundle>(value, "/handlerBundleHash")?;
192    }
193    hash_id::<ProtocolStackSource>(root.req("stackSourceHash")?, "/stackSourceHash")?;
194    hash_id::<Compiler>(root.req("compilerHash")?, "/compilerHash")?;
195    let deps = Obj::new(
196        root.req("dependencies")?,
197        "/dependencies",
198        &["programs", "stacks", "sdkExtensions"],
199    )?;
200    sorted_entries(
201        deps.req("programs")?,
202        "/dependencies/programs",
203        |entry, path| {
204            let entry = Obj::new(
205                entry,
206                path,
207                &[
208                    "alias",
209                    "programId",
210                    "programSpecHash",
211                    "programReleaseHash",
212                ],
213            )?;
214            let alias = identifier_value(entry.req("alias")?, &entry.path_of("alias"))?;
215            pubkey(entry.req("programId")?, &entry.path_of("programId"))?;
216            hash_id::<ProgramSpec>(
217                entry.req("programSpecHash")?,
218                &entry.path_of("programSpecHash"),
219            )?;
220            if let Some(value) = entry.opt("programReleaseHash")? {
221                hash_id::<ProgramRelease>(value, &entry.path_of("programReleaseHash"))?;
222            }
223            Ok(alias.to_string())
224        },
225    )?;
226    sorted_entries(
227        deps.req("stacks")?,
228        "/dependencies/stacks",
229        |entry, path| {
230            let entry = Obj::new(entry, path, &["alias", "stackManifestHash"])?;
231            let alias = identifier_value(entry.req("alias")?, &entry.path_of("alias"))?;
232            hash_id::<StackManifest>(
233                entry.req("stackManifestHash")?,
234                &entry.path_of("stackManifestHash"),
235            )?;
236            Ok(alias.to_string())
237        },
238    )?;
239    sorted_entries(
240        deps.req("sdkExtensions")?,
241        "/dependencies/sdkExtensions",
242        |entry, path| {
243            let entry = Obj::new(entry, path, &["alias", "sdkExtensionHash"])?;
244            let alias = identifier_value(entry.req("alias")?, &entry.path_of("alias"))?;
245            hash_id::<SdkExtension>(
246                entry.req("sdkExtensionHash")?,
247                &entry.path_of("sdkExtensionHash"),
248            )?;
249            Ok(alias.to_string())
250        },
251    )?;
252    model(value)
253}
254
255/// Validate an array whose entries carry a key that must be strictly
256/// increasing (sorted and unique).
257pub(crate) fn sorted_entries(
258    value: &Value,
259    path: &str,
260    entry: impl Fn(&Value, &str) -> ProtocolResult<String>,
261) -> ProtocolResult<()> {
262    let values = as_array(value, path, MAX_DEPENDENCIES)?;
263    let mut previous: Option<String> = None;
264    for (i, item) in values.iter().enumerate() {
265        let item_path = index(path, i);
266        let key = entry(item, &item_path)?;
267        if let Some(previous) = &previous {
268            if key == *previous {
269                return Err(err(
270                    "duplicate-entry",
271                    &item_path,
272                    format!("'{key}' is listed twice"),
273                ));
274            }
275            if key < *previous {
276                return Err(err(
277                    "unsorted-entries",
278                    &item_path,
279                    "entries must be sorted",
280                ));
281            }
282        }
283        previous = Some(key);
284    }
285    Ok(())
286}
287
288// ---------------------------------------------------------------------------
289// Review
290// ---------------------------------------------------------------------------
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase", deny_unknown_fields)]
294pub struct ProtocolReleaseReviewV1 {
295    pub schema: String,
296    pub release_hash: HashId<ProtocolRelease>,
297    pub review_key_id: String,
298    pub trust_tier: TrustTier,
299    /// Base64 (standard, padded) Ed25519 signature over
300    /// [`review_signing_message`].
301    pub signature: String,
302}
303
304/// The trust tier a deployment runs at.
305///
306/// - `reviewed`: a reviewer signed the release (handlers may run).
307/// - `unreviewed`: no review. Only template-only releases on private
308///   deployments may run at this tier, so no author code ever runs
309///   unreviewed.
310///
311/// A review document always attests `reviewed`: `unreviewed` is the absence
312/// of a review, so [`validate_protocol_release_review_value`] rejects it.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(rename_all = "camelCase")]
315pub enum TrustTier {
316    Reviewed,
317    Unreviewed,
318}
319
320impl TrustTier {
321    pub const ALL: [&'static str; 2] = ["reviewed", "unreviewed"];
322
323    pub const fn as_str(self) -> &'static str {
324        match self {
325            Self::Reviewed => "reviewed",
326            Self::Unreviewed => "unreviewed",
327        }
328    }
329
330    pub fn parse(value: &str) -> Option<Self> {
331        match value {
332            "reviewed" => Some(Self::Reviewed),
333            "unreviewed" => Some(Self::Unreviewed),
334            _ => None,
335        }
336    }
337}
338
339impl ProtocolReleaseReviewV1 {
340    pub fn hash(&self) -> Result<HashId<ProtocolReleaseReview>, HashError> {
341        hash_jcs::<ProtocolReleaseReview, _>(self)
342    }
343
344    pub fn signing_message(&self) -> Vec<u8> {
345        review_signing_message(&self.release_hash, &self.review_key_id, self.trust_tier)
346    }
347
348    /// The 64 raw signature bytes. Validation already checked the encoding.
349    pub fn signature_bytes(&self) -> [u8; 64] {
350        let mut out = [0u8; 64];
351        if let Some(bytes) = decode_base64(&self.signature) {
352            if bytes.len() == 64 {
353                out.copy_from_slice(&bytes);
354            }
355        }
356        out
357    }
358}
359
360/// The exact bytes a reviewer signs:
361/// `arete.protocol-release-review/v1\n<keyId>\n<trustTier>\n<releaseHash>`.
362/// Hosts and the API verify against these bytes with the same public keys.
363pub fn review_signing_message(
364    release_hash: &HashId<ProtocolRelease>,
365    review_key_id: &str,
366    trust_tier: TrustTier,
367) -> Vec<u8> {
368    format!(
369        "{PROTOCOL_RELEASE_REVIEW_SCHEMA_V1}\n{review_key_id}\n{}\n{release_hash}",
370        trust_tier.as_str()
371    )
372    .into_bytes()
373}
374
375pub fn validate_protocol_release_review_value(
376    value: &Value,
377) -> ProtocolResult<ProtocolReleaseReviewV1> {
378    reject_floats(value, "")?;
379    let root = Obj::new(
380        value,
381        "",
382        &[
383            "schema",
384            "releaseHash",
385            "reviewKeyId",
386            "trustTier",
387            "signature",
388        ],
389    )?;
390    schema_tag(
391        root.req("schema")?,
392        "/schema",
393        PROTOCOL_RELEASE_REVIEW_SCHEMA_V1,
394    )?;
395    hash_id::<ProtocolRelease>(root.req("releaseHash")?, "/releaseHash")?;
396    key_id(root.req("reviewKeyId")?, "/reviewKeyId")?;
397    // A review attests the reviewed tier; `unreviewed` is the absence of one.
398    one_of(root.req("trustTier")?, "/trustTier", &["reviewed"])?;
399    let signature = as_str(root.req("signature")?, "/signature")?;
400    match decode_base64(signature) {
401        Some(bytes) if bytes.len() == 64 => {}
402        _ => {
403            return Err(err(
404                "invalid-signature",
405                "/signature",
406                "expected a base64 64-byte Ed25519 signature",
407            ))
408        }
409    }
410    model(value)
411}
412
413/// `^[a-z0-9][a-z0-9-]{0,63}$`.
414pub(crate) fn key_id<'a>(value: &'a Value, path: &str) -> ProtocolResult<&'a str> {
415    let text = as_str(value, path)?;
416    let bytes = text.as_bytes();
417    let valid = !bytes.is_empty()
418        && bytes.len() <= 64
419        && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit())
420        && bytes
421            .iter()
422            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-');
423    if valid {
424        Ok(text)
425    } else {
426        Err(err(
427            "invalid-identifier",
428            path,
429            "key ids match ^[a-z0-9][a-z0-9-]{0,63}$",
430        ))
431    }
432}
433
434const BASE64_ALPHABET: &[u8; 64] =
435    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
436
437/// Strict standard base64 with padding. Returns `None` for any
438/// non-canonical encoding.
439pub fn decode_base64(text: &str) -> Option<Vec<u8>> {
440    let bytes = text.as_bytes();
441    if !bytes.len().is_multiple_of(4) {
442        return None;
443    }
444    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
445    for (chunk_index, chunk) in bytes.chunks(4).enumerate() {
446        let last = chunk_index == bytes.len() / 4 - 1;
447        let mut values = [0u8; 4];
448        let mut padding = 0;
449        for (i, byte) in chunk.iter().enumerate() {
450            if *byte == b'=' {
451                if !last || i < 2 {
452                    return None;
453                }
454                padding += 1;
455                values[i] = 0;
456            } else {
457                if padding > 0 {
458                    return None;
459                }
460                values[i] = BASE64_ALPHABET.iter().position(|c| c == byte)? as u8;
461            }
462        }
463        let n = (u32::from(values[0]) << 18)
464            | (u32::from(values[1]) << 12)
465            | (u32::from(values[2]) << 6)
466            | u32::from(values[3]);
467        out.push((n >> 16) as u8);
468        if padding < 2 {
469            out.push((n >> 8) as u8);
470        }
471        if padding < 1 {
472            out.push(n as u8);
473        }
474        // Reject non-zero bits hidden in the padding.
475        if (padding == 1 && values[2] & 0b11 != 0) || (padding == 2 && values[1] & 0b1111 != 0) {
476            return None;
477        }
478    }
479    Some(out)
480}
481
482pub fn encode_base64(bytes: &[u8]) -> String {
483    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
484    for chunk in bytes.chunks(3) {
485        let n = (u32::from(chunk[0]) << 16)
486            | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
487            | u32::from(*chunk.get(2).unwrap_or(&0));
488        out.push(BASE64_ALPHABET[(n >> 18) as usize & 63] as char);
489        out.push(BASE64_ALPHABET[(n >> 12) as usize & 63] as char);
490        out.push(if chunk.len() > 1 {
491            BASE64_ALPHABET[(n >> 6) as usize & 63] as char
492        } else {
493            '='
494        });
495        out.push(if chunk.len() > 2 {
496            BASE64_ALPHABET[n as usize & 63] as char
497        } else {
498            '='
499        });
500    }
501    out
502}
503
504// ---------------------------------------------------------------------------
505// Build receipt
506// ---------------------------------------------------------------------------
507
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(rename_all = "camelCase", deny_unknown_fields)]
510pub struct ProtocolBuildReceiptV1 {
511    pub schema: String,
512    pub source_tree_sha256: String,
513    pub lockfile_sha256: String,
514    pub dependencies: Vec<BuildDependencyV1>,
515    pub compiler: BuildCompilerV1,
516    pub outputs: BuildOutputsV1,
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(rename_all = "camelCase", deny_unknown_fields)]
521pub struct BuildDependencyV1 {
522    pub name: String,
523    pub version: String,
524    pub integrity: String,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase", deny_unknown_fields)]
529pub struct BuildCompilerV1 {
530    pub name: String,
531    pub version: String,
532    pub esbuild: String,
533}
534
535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
536#[serde(rename_all = "camelCase", deny_unknown_fields)]
537pub struct BuildOutputsV1 {
538    pub spec_hash: HashId<ProtocolSpec>,
539    pub handler_bundle_hash: Option<HashId<ProtocolHandlerBundle>>,
540    pub stack_source_hash: HashId<ProtocolStackSource>,
541    pub release_hash: HashId<ProtocolRelease>,
542}
543
544pub fn validate_protocol_build_receipt_value(
545    value: &Value,
546) -> ProtocolResult<ProtocolBuildReceiptV1> {
547    reject_floats(value, "")?;
548    let root = Obj::new(
549        value,
550        "",
551        &[
552            "schema",
553            "sourceTreeSha256",
554            "lockfileSha256",
555            "dependencies",
556            "compiler",
557            "outputs",
558        ],
559    )?;
560    schema_tag(
561        root.req("schema")?,
562        "/schema",
563        PROTOCOL_BUILD_RECEIPT_SCHEMA_V1,
564    )?;
565    sha256_hex(root.req("sourceTreeSha256")?, "/sourceTreeSha256")?;
566    sha256_hex(root.req("lockfileSha256")?, "/lockfileSha256")?;
567    sorted_entries(root.req("dependencies")?, "/dependencies", |entry, path| {
568        let entry = Obj::new(entry, path, &["name", "version", "integrity"])?;
569        let name = bounded_str(entry.req("name")?, &entry.path_of("name"), 214)?;
570        bounded_str(entry.req("version")?, &entry.path_of("version"), 64)?;
571        bounded_str(entry.req("integrity")?, &entry.path_of("integrity"), 256)?;
572        Ok(name.to_string())
573    })?;
574    let compiler = Obj::new(
575        root.req("compiler")?,
576        "/compiler",
577        &["name", "version", "esbuild"],
578    )?;
579    bounded_str(compiler.req("name")?, "/compiler/name", 128)?;
580    semver(compiler.req("version")?, "/compiler/version")?;
581    semver(compiler.req("esbuild")?, "/compiler/esbuild")?;
582    let outputs = Obj::new(
583        root.req("outputs")?,
584        "/outputs",
585        &[
586            "specHash",
587            "handlerBundleHash",
588            "stackSourceHash",
589            "releaseHash",
590        ],
591    )?;
592    hash_id::<ProtocolSpec>(outputs.req("specHash")?, "/outputs/specHash")?;
593    if let Some(value) = outputs.nullable("handlerBundleHash")? {
594        hash_id::<ProtocolHandlerBundle>(value, "/outputs/handlerBundleHash")?;
595    }
596    hash_id::<ProtocolStackSource>(outputs.req("stackSourceHash")?, "/outputs/stackSourceHash")?;
597    hash_id::<ProtocolRelease>(outputs.req("releaseHash")?, "/outputs/releaseHash")?;
598    model(value)
599}
600
601// ---------------------------------------------------------------------------
602// Resource
603// ---------------------------------------------------------------------------
604
605#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
606#[serde(rename_all = "camelCase", deny_unknown_fields)]
607pub struct ProtocolResourceV1 {
608    pub schema: String,
609    pub name: String,
610    /// Lowercase hex SHA-256 of the canonical JSON of the spec's resource
611    /// field schema.
612    pub schema_sha256: String,
613    pub value: Value,
614}
615
616impl ProtocolResourceV1 {
617    pub fn hash(&self) -> Result<HashId<ProtocolResource>, HashError> {
618        hash_jcs::<ProtocolResource, _>(self)
619    }
620}
621
622pub fn validate_protocol_resource_value(value: &Value) -> ProtocolResult<ProtocolResourceV1> {
623    reject_floats(value, "")?;
624    let root = Obj::new(value, "", &["schema", "name", "schemaSha256", "value"])?;
625    schema_tag(root.req("schema")?, "/schema", PROTOCOL_RESOURCE_SCHEMA_V1)?;
626    identifier_value(root.req("name")?, "/name")?;
627    sha256_hex(root.req("schemaSha256")?, "/schemaSha256")?;
628    root.req("value")?;
629    model(value)
630}