Skip to main content

arete_hash/protocol/
derive.rs

1//! Release derivation.
2//!
3//! [`derive_release`] builds the `arete.protocol-release/v1` document of a
4//! spec, its optional handler bundle and its generated stack source under a
5//! given compiler identity. It is a port of `buildRelease` and
6//! `releaseDependencies` in `@usearete/protocol`
7//! (`src/build/artifacts.ts`); both run the shared vectors in
8//! `test-vectors/protocol-release/` and must agree byte for byte.
9//!
10//! A hosted service that accepts a protocol as one file (the spec, plus the
11//! handler bundle when a step has a handler) derives everything else itself
12//! with [`derive_server_release`]: the interim stack source, the release and
13//! a build receipt, all under the fixed [`server_compiler`] identity. A local
14//! `arete-protocol build` uses its own package and esbuild version as the
15//! compiler identity and the definition's stack-source options, so its
16//! release hash differs from the server-derived one only through:
17//!
18//! - the compiler identity (`compilerHash`), and
19//! - the stack source, whose generator identity is the server's and whose
20//!   program sources are the generator defaults.
21//!
22//! `arete-protocol build --server-release` prints the server-derived hash, so
23//! authors can predict it before uploading.
24
25use sha2::{Digest, Sha256};
26
27use super::hash_handler_bundle;
28use super::model::{ProtocolProgramV1, ProtocolSpecV1};
29use super::release::{
30    BuildCompilerV1, BuildOutputsV1, GeneratorV1, ProgramDependencyV1, ProtocolBuildReceiptV1,
31    ProtocolReleaseV1, ProtocolStackSourceV1, ReleaseDependenciesV1, StackDependencyV1,
32    PROTOCOL_BUILD_RECEIPT_SCHEMA_V1, PROTOCOL_RELEASE_SCHEMA_V1,
33};
34use super::stack::{generate_stack_source, StackGenerationError, StackSourceOptions};
35use crate::{
36    canonicalize_jcs, Compiler, CompilerSourceV1, CompilerV1, HashError, HashId,
37    ProtocolHandlerBundle, ProtocolRelease, ProtocolSpec, ProtocolStackSource,
38};
39
40/// The compiler and stack-generator name of server-side derivation.
41pub const SERVER_COMPILER_NAME: &str = "arete-protocol-server";
42
43/// The version of server-side derivation. It changes only when the derived
44/// documents for the same inputs change, never with crate releases, so
45/// re-uploading the same spec stays idempotent across server upgrades.
46pub const SERVER_DERIVATION_VERSION: &str = "1.0.0";
47
48/// The server never bundles: the uploaded bundle is used as is. The receipt
49/// contract requires a semver here, so the server records `0.0.0`.
50pub const SERVER_COMPILER_ESBUILD: &str = "0.0.0";
51
52/// The SHA-256 of zero bytes: the lockfile digest of a derivation that read
53/// no lockfile.
54const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
55
56/// A spec, bundle or stack the release cannot be derived from.
57#[derive(Debug, thiserror::Error)]
58pub enum ReleaseDerivationError {
59    #[error("the spec has handler steps but no handler bundle was supplied")]
60    MissingBundle,
61    #[error("a template-only spec cannot carry a handler bundle")]
62    UnexpectedBundle,
63    #[error("the handler bundle is empty")]
64    EmptyBundle,
65    #[error("the stack source cannot be generated: {0}")]
66    Stack(#[from] StackGenerationError),
67    #[error("a derived document cannot be hashed: {0}")]
68    Hash(#[from] HashError),
69}
70
71impl ReleaseDerivationError {
72    /// A stable, kebab-case code for API errors.
73    pub fn code(&self) -> &'static str {
74        match self {
75            Self::MissingBundle | Self::UnexpectedBundle | Self::EmptyBundle => {
76                "protocol-bundle-mismatch"
77            }
78            Self::Stack(_) => "protocol-stack-generation-failed",
79            Self::Hash(_) => "protocol-invalid-document",
80        }
81    }
82}
83
84impl BuildCompilerV1 {
85    /// The `compiler` hash (`arete.compiler/v1`, framed tuple): one source
86    /// per identity field (`esbuild`, `name`, `version`), matching
87    /// `compilerHash` in `@usearete/protocol`.
88    pub fn compiler_hash(&self) -> Result<HashId<Compiler>, HashError> {
89        CompilerV1::new([
90            CompilerSourceV1::new("esbuild", self.esbuild.as_bytes()),
91            CompilerSourceV1::new("name", self.name.as_bytes()),
92            CompilerSourceV1::new("version", self.version.as_bytes()),
93        ])?
94        .hash()
95    }
96}
97
98/// The compiler identity of server-side derivation.
99pub fn server_compiler() -> BuildCompilerV1 {
100    BuildCompilerV1 {
101        name: SERVER_COMPILER_NAME.to_string(),
102        version: SERVER_DERIVATION_VERSION.to_string(),
103        esbuild: SERVER_COMPILER_ESBUILD.to_string(),
104    }
105}
106
107/// The stack generator identity of server-side derivation.
108pub fn server_stack_generator() -> GeneratorV1 {
109    GeneratorV1 {
110        name: SERVER_COMPILER_NAME.to_string(),
111        version: SERVER_DERIVATION_VERSION.to_string(),
112    }
113}
114
115/// Stack-source options of server-side derivation: the interim profile, the
116/// generator defaults for program sources, and the server generator identity.
117pub fn server_stack_source_options() -> StackSourceOptions {
118    StackSourceOptions {
119        generator: Some(server_stack_generator()),
120        ..StackSourceOptions::default()
121    }
122}
123
124/// Release dependencies: every ProgramSpec program and stack, sorted by
125/// alias; builtins excluded.
126pub fn release_dependencies(spec: &ProtocolSpecV1) -> ReleaseDependenciesV1 {
127    // BTreeMap iteration is raw UTF-8 byte order, like `compareUtf8`.
128    let programs = spec
129        .programs
130        .iter()
131        .filter_map(|(alias, program)| match program {
132            ProtocolProgramV1::ProgramSpec {
133                program_id,
134                program_spec_hash,
135                program_release_hash,
136            } => Some(ProgramDependencyV1 {
137                alias: alias.clone(),
138                program_id: program_id.clone(),
139                program_spec_hash: *program_spec_hash,
140                program_release_hash: *program_release_hash,
141            }),
142            ProtocolProgramV1::Builtin { .. } => None,
143        })
144        .collect();
145    let stacks = spec
146        .stacks
147        .iter()
148        .map(|(alias, stack)| StackDependencyV1 {
149            alias: alias.clone(),
150            stack_manifest_hash: stack.stack_manifest_hash,
151        })
152        .collect();
153    ReleaseDependenciesV1 {
154        programs,
155        stacks,
156        sdk_extensions: Vec::new(),
157    }
158}
159
160fn bundle_hash(
161    spec: &ProtocolSpecV1,
162    handler_bundle: Option<&[u8]>,
163) -> Result<Option<HashId<ProtocolHandlerBundle>>, ReleaseDerivationError> {
164    match (spec.has_handlers(), handler_bundle) {
165        (true, Some([])) => Err(ReleaseDerivationError::EmptyBundle),
166        (true, Some(bytes)) => Ok(Some(hash_handler_bundle(bytes)?)),
167        (true, None) => Err(ReleaseDerivationError::MissingBundle),
168        (false, Some(_)) => Err(ReleaseDerivationError::UnexpectedBundle),
169        (false, None) => Ok(None),
170    }
171}
172
173/// The release document of a spec, its handler bundle (exactly when a step
174/// has a handler) and its stack source, under `compiler`.
175pub fn derive_release(
176    spec: &ProtocolSpecV1,
177    handler_bundle: Option<&[u8]>,
178    stack_source: &ProtocolStackSourceV1,
179    compiler: &BuildCompilerV1,
180) -> Result<ProtocolReleaseV1, ReleaseDerivationError> {
181    Ok(ProtocolReleaseV1 {
182        schema: PROTOCOL_RELEASE_SCHEMA_V1.to_string(),
183        protocol: spec.name.clone(),
184        version: spec.version.clone(),
185        spec_hash: spec.hash()?,
186        handler_bundle_hash: bundle_hash(spec, handler_bundle)?,
187        stack_source_hash: stack_source.hash()?,
188        compiler_hash: compiler.compiler_hash()?,
189        dependencies: release_dependencies(spec),
190    })
191}
192
193/// Everything server-side derivation produces from one upload.
194#[derive(Debug, Clone, PartialEq)]
195pub struct ServerDerivedRelease {
196    pub spec_hash: HashId<ProtocolSpec>,
197    pub handler_bundle_hash: Option<HashId<ProtocolHandlerBundle>>,
198    pub stack_source: ProtocolStackSourceV1,
199    pub stack_source_hash: HashId<ProtocolStackSource>,
200    pub release: ProtocolReleaseV1,
201    pub release_hash: HashId<ProtocolRelease>,
202    /// A receipt for what the server derived: the source tree is the
203    /// uploaded files (`handlers.mjs`, `protocol-spec.json`), there is no
204    /// lockfile and no bundled dependency.
205    pub receipt: ProtocolBuildReceiptV1,
206}
207
208fn sha256_hex(bytes: &[u8]) -> String {
209    hex::encode(Sha256::digest(bytes))
210}
211
212/// Derive the stack source, release and receipt of an uploaded spec (and
213/// bundle) under [`server_compiler`]. Deterministic: identical inputs give
214/// identical documents and hashes.
215pub fn derive_server_release(
216    spec: &ProtocolSpecV1,
217    handler_bundle: Option<&[u8]>,
218) -> Result<ServerDerivedRelease, ReleaseDerivationError> {
219    let handler_bundle_hash = bundle_hash(spec, handler_bundle)?;
220    let stack_source = generate_stack_source(spec, &server_stack_source_options())?;
221    let compiler = server_compiler();
222    let release = derive_release(spec, handler_bundle, &stack_source, &compiler)?;
223    let release_hash = release.hash()?;
224    let spec_hash = release.spec_hash;
225    let stack_source_hash = release.stack_source_hash;
226
227    // Source tree: `[path, sha256]` pairs sorted by path, as canonical JSON,
228    // the same shape `arete-protocol build` hashes.
229    let mut tree: Vec<(String, String)> = Vec::with_capacity(2);
230    if let Some(bytes) = handler_bundle {
231        tree.push(("handlers.mjs".into(), sha256_hex(bytes)));
232    }
233    tree.push((
234        "protocol-spec.json".into(),
235        sha256_hex(&canonicalize_jcs(spec)?),
236    ));
237    let source_tree_sha256 = sha256_hex(&canonicalize_jcs(&tree)?);
238
239    let receipt = ProtocolBuildReceiptV1 {
240        schema: PROTOCOL_BUILD_RECEIPT_SCHEMA_V1.to_string(),
241        source_tree_sha256,
242        lockfile_sha256: EMPTY_SHA256.to_string(),
243        dependencies: Vec::new(),
244        compiler,
245        outputs: BuildOutputsV1 {
246            spec_hash,
247            handler_bundle_hash,
248            stack_source_hash,
249            release_hash,
250        },
251    };
252    Ok(ServerDerivedRelease {
253        spec_hash,
254        handler_bundle_hash,
255        stack_source,
256        stack_source_hash,
257        release,
258        release_hash,
259        receipt,
260    })
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn the_empty_lockfile_digest_is_sha256_of_nothing() {
269        assert_eq!(sha256_hex(&[]), EMPTY_SHA256);
270    }
271
272    #[test]
273    fn the_server_compiler_is_a_valid_receipt_compiler() {
274        let compiler = server_compiler();
275        assert_eq!(compiler.name, SERVER_COMPILER_NAME);
276        compiler.compiler_hash().unwrap();
277    }
278}