Skip to main content

arete_hash/
projection.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::{
5    hash_framed_tuple, hash_jcs, parse_json_bytes_strict, Compiler, HashError, HashId,
6    ProgramRelease, ProgramSpec, SdkDefinition, TupleField,
7};
8
9pub const COMPILER_SCHEMA_V1: &str = "arete.compiler/v1";
10pub const SDK_DEFINITION_SCHEMA_V1: &str = "arete.sdk-definition/v1";
11pub const SDK_DEFINITION_PROGRAM_SPEC_INPUT_KIND: &str = "program-spec";
12pub const OSS_DECODER_ENGINE_ID: &str = "arete-oss-generated-decoder/v1";
13pub const PROGRAM_RELEASE_SCHEMA_V1: &str = "arete.program-release/v1";
14pub const PROGRAM_RELEASE_SCHEMA_V2: &str = "arete.program-release/v2";
15pub const HOSTED_MANAGED_RELEASE_PROFILE: &str = "hosted-managed";
16pub const OSS_GENERATED_RELEASE_PROFILE: &str = "oss-generated";
17pub const SOLANA_EXECUTABLE_IDENTITY_SCHEMA_V1: &str = "arete.solana-executable-identity/v1";
18pub const SOLANA_BPF_LOADER_V2_PROGRAM_ID: &str = "BPFLoader2111111111111111111111111111111111";
19pub const SOLANA_BPF_UPGRADEABLE_LOADER_PROGRAM_ID: &str =
20    "BPFLoaderUpgradeab1e11111111111111111111111";
21pub const SOLANA_EXECUTABLE_PAYLOAD_SHA256_PREFIX: &str = "sha256:";
22
23/// Remove the declared top-level self-hash field and no other field.
24///
25/// Nested `artifactHash` fields and all other hash-like fields are retained.
26pub fn project_without_artifact_hash(value: &Value) -> Result<Value, HashError> {
27    let mut projection = value
28        .as_object()
29        .cloned()
30        .ok_or(HashError::InvalidSelfHashProjection)?;
31    projection.remove("artifactHash");
32    Ok(Value::Object(projection))
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct CompilerSourceV1 {
37    pub path: String,
38    pub bytes: Vec<u8>,
39}
40
41impl CompilerSourceV1 {
42    pub fn new(path: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
43        Self {
44            path: path.into(),
45            bytes: bytes.into(),
46        }
47    }
48}
49
50/// Frozen v1 identity projection for the OSS SDK compiler source tree.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct CompilerV1 {
53    pub schema: String,
54    pub sources: Vec<CompilerSourceV1>,
55}
56
57impl CompilerV1 {
58    pub fn new(sources: impl IntoIterator<Item = CompilerSourceV1>) -> Result<Self, HashError> {
59        let mut projection = Self {
60            schema: COMPILER_SCHEMA_V1.to_string(),
61            sources: sources.into_iter().collect(),
62        };
63        projection
64            .sources
65            .sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
66        projection.validate()?;
67        Ok(projection)
68    }
69
70    pub fn hash(&self) -> Result<HashId<Compiler>, HashError> {
71        self.validate()?;
72        let mut fields = Vec::with_capacity(self.sources.len() + 1);
73        fields.push(TupleField::new("schema", self.schema.as_bytes()));
74        fields.extend(
75            self.sources
76                .iter()
77                .map(|source| TupleField::new(&source.path, &source.bytes)),
78        );
79        hash_framed_tuple(&fields)
80    }
81
82    fn validate(&self) -> Result<(), HashError> {
83        if self.schema != COMPILER_SCHEMA_V1 {
84            return Err(HashError::UnknownVersion(self.schema.clone()));
85        }
86        if self.sources.is_empty() {
87            return Err(HashError::InvalidProjection {
88                projection: "compiler",
89                reason: "sources must not be empty".to_string(),
90            });
91        }
92        let mut previous: Option<&[u8]> = None;
93        for source in &self.sources {
94            if source.path.is_empty() || source.path == "schema" {
95                return Err(HashError::InvalidProjection {
96                    projection: "compiler",
97                    reason: format!("invalid source path '{}'", source.path),
98                });
99            }
100            if let Some(previous) = previous {
101                match previous.cmp(source.path.as_bytes()) {
102                    std::cmp::Ordering::Greater => {
103                        return Err(HashError::InvalidProjection {
104                            projection: "compiler",
105                            reason: "sources must be sorted by raw UTF-8 path bytes".to_string(),
106                        })
107                    }
108                    std::cmp::Ordering::Equal => {
109                        return Err(HashError::InvalidProjection {
110                            projection: "compiler",
111                            reason: format!("duplicate source path '{}'", source.path),
112                        })
113                    }
114                    std::cmp::Ordering::Less => {}
115                }
116            }
117            previous = Some(source.path.as_bytes());
118        }
119        Ok(())
120    }
121}
122
123/// Frozen v1 identity projection for one generated program SDK definition.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct SdkDefinitionV1 {
127    pub schema: String,
128    pub input_kind: String,
129    pub input_hash: HashId<ProgramSpec>,
130    pub compiler_hash: HashId<Compiler>,
131}
132
133impl SdkDefinitionV1 {
134    pub fn new(input_hash: HashId<ProgramSpec>, compiler_hash: HashId<Compiler>) -> Self {
135        Self {
136            schema: SDK_DEFINITION_SCHEMA_V1.to_string(),
137            input_kind: SDK_DEFINITION_PROGRAM_SPEC_INPUT_KIND.to_string(),
138            input_hash,
139            compiler_hash,
140        }
141    }
142
143    pub fn hash(&self) -> Result<HashId<SdkDefinition>, HashError> {
144        if self.schema != SDK_DEFINITION_SCHEMA_V1 {
145            return Err(HashError::UnknownVersion(self.schema.clone()));
146        }
147        if self.input_kind != SDK_DEFINITION_PROGRAM_SPEC_INPUT_KIND {
148            return Err(HashError::InvalidProjection {
149                projection: "SDK definition",
150                reason: format!(
151                    "inputKind must be '{}', not '{}'",
152                    SDK_DEFINITION_PROGRAM_SPEC_INPUT_KIND, self.input_kind
153                ),
154            });
155        }
156        hash_jcs(self)
157    }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase", deny_unknown_fields)]
162pub struct SolanaExecutableIdentityV1 {
163    pub schema: String,
164    pub genesis_hash: String,
165    pub loader: SolanaExecutableLoaderV1,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(
170    tag = "kind",
171    rename_all = "kebab-case",
172    rename_all_fields = "camelCase",
173    deny_unknown_fields
174)]
175pub enum SolanaExecutableLoaderV1 {
176    BpfLoaderV2(SolanaBpfLoaderV2IdentityV1),
177    BpfUpgradeableLoader(SolanaBpfUpgradeableLoaderIdentityV1),
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase", deny_unknown_fields)]
182pub struct SolanaBpfLoaderV2IdentityV1 {
183    pub loader_program_id: String,
184    pub executable_payload_sha256: String,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase", deny_unknown_fields)]
189pub struct SolanaBpfUpgradeableLoaderIdentityV1 {
190    pub loader_program_id: String,
191    pub program_data_address: String,
192    pub deployment_slot: String,
193    pub upgrade_authority: SolanaUpgradeAuthorityV1,
194    pub executable_payload_sha256: String,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(
199    tag = "kind",
200    rename_all = "kebab-case",
201    rename_all_fields = "camelCase",
202    deny_unknown_fields
203)]
204pub enum SolanaUpgradeAuthorityV1 {
205    None(SolanaNoUpgradeAuthorityV1),
206    Address(SolanaUpgradeAuthorityAddressV1),
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct SolanaNoUpgradeAuthorityV1 {}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "camelCase", deny_unknown_fields)]
215pub struct SolanaUpgradeAuthorityAddressV1 {
216    pub address: String,
217}
218
219impl SolanaExecutableIdentityV1 {
220    pub fn new(
221        genesis_hash: impl Into<String>,
222        loader: SolanaExecutableLoaderV1,
223    ) -> Result<Self, HashError> {
224        let identity = Self {
225            schema: SOLANA_EXECUTABLE_IDENTITY_SCHEMA_V1.to_string(),
226            genesis_hash: genesis_hash.into(),
227            loader,
228        };
229        validate_solana_executable_identity_v1(&identity)?;
230        Ok(identity)
231    }
232}
233
234impl SolanaExecutableLoaderV1 {
235    pub fn bpf_loader_v2(executable_payload_sha256: impl Into<String>) -> Result<Self, HashError> {
236        let loader = Self::BpfLoaderV2(SolanaBpfLoaderV2IdentityV1 {
237            loader_program_id: SOLANA_BPF_LOADER_V2_PROGRAM_ID.to_string(),
238            executable_payload_sha256: executable_payload_sha256.into(),
239        });
240        validate_solana_executable_loader_v1(&loader)?;
241        Ok(loader)
242    }
243
244    pub fn bpf_upgradeable_loader(
245        program_data_address: impl Into<String>,
246        deployment_slot: u64,
247        upgrade_authority: SolanaUpgradeAuthorityV1,
248        executable_payload_sha256: impl Into<String>,
249    ) -> Result<Self, HashError> {
250        let loader = Self::BpfUpgradeableLoader(SolanaBpfUpgradeableLoaderIdentityV1 {
251            loader_program_id: SOLANA_BPF_UPGRADEABLE_LOADER_PROGRAM_ID.to_string(),
252            program_data_address: program_data_address.into(),
253            deployment_slot: deployment_slot.to_string(),
254            upgrade_authority,
255            executable_payload_sha256: executable_payload_sha256.into(),
256        });
257        validate_solana_executable_loader_v1(&loader)?;
258        Ok(loader)
259    }
260}
261
262impl SolanaUpgradeAuthorityV1 {
263    pub const fn none() -> Self {
264        Self::None(SolanaNoUpgradeAuthorityV1 {})
265    }
266
267    pub fn address(address: impl Into<String>) -> Result<Self, HashError> {
268        let address = address.into();
269        validate_base58_32(&address, "upgradeAuthority.address")?;
270        Ok(Self::Address(SolanaUpgradeAuthorityAddressV1 { address }))
271    }
272}
273
274pub fn parse_solana_executable_identity_v1(
275    bytes: &[u8],
276) -> Result<SolanaExecutableIdentityV1, HashError> {
277    let value = parse_json_bytes_strict(bytes)?;
278    let identity: SolanaExecutableIdentityV1 = serde_json::from_value(value)
279        .map_err(|error| release_projection_error(error.to_string()))?;
280    validate_solana_executable_identity_v1(&identity)?;
281    Ok(identity)
282}
283
284pub fn validate_solana_executable_identity_v1(
285    identity: &SolanaExecutableIdentityV1,
286) -> Result<(), HashError> {
287    if identity.schema != SOLANA_EXECUTABLE_IDENTITY_SCHEMA_V1 {
288        return Err(HashError::UnknownVersion(identity.schema.clone()));
289    }
290    validate_base58_32(&identity.genesis_hash, "genesisHash")?;
291    validate_solana_executable_loader_v1(&identity.loader)
292}
293
294fn validate_solana_executable_loader_v1(
295    loader: &SolanaExecutableLoaderV1,
296) -> Result<(), HashError> {
297    match loader {
298        SolanaExecutableLoaderV1::BpfLoaderV2(loader) => {
299            validate_loader_program_id(
300                &loader.loader_program_id,
301                SOLANA_BPF_LOADER_V2_PROGRAM_ID,
302                "bpf-loader-v2",
303            )?;
304            validate_sha256_digest(&loader.executable_payload_sha256, "executablePayloadSha256")
305        }
306        SolanaExecutableLoaderV1::BpfUpgradeableLoader(loader) => {
307            validate_loader_program_id(
308                &loader.loader_program_id,
309                SOLANA_BPF_UPGRADEABLE_LOADER_PROGRAM_ID,
310                "bpf-upgradeable-loader",
311            )?;
312            validate_base58_32(&loader.program_data_address, "programDataAddress")?;
313            validate_deployment_slot(&loader.deployment_slot)?;
314            if let SolanaUpgradeAuthorityV1::Address(authority) = &loader.upgrade_authority {
315                validate_base58_32(&authority.address, "upgradeAuthority.address")?;
316            }
317            validate_sha256_digest(&loader.executable_payload_sha256, "executablePayloadSha256")
318        }
319    }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase", deny_unknown_fields)]
324pub struct HostedManagedProgramReleaseV2 {
325    pub schema: String,
326    pub release_profile: String,
327    pub program_id: String,
328    pub program_spec_hash: HashId<ProgramSpec>,
329    pub idl_content_hash: HashId<crate::IdlContent>,
330    pub normalized_idl_hash: HashId<crate::IdlNormalized>,
331    pub decoder_abi_version: String,
332    pub decoder_engine_id: String,
333    pub decoder_binding_id: String,
334    pub executable_identity: SolanaExecutableIdentityV1,
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct HostedManagedProgramReleaseV2Fields {
339    pub program_id: String,
340    pub program_spec_hash: HashId<ProgramSpec>,
341    pub idl_content_hash: HashId<crate::IdlContent>,
342    pub normalized_idl_hash: HashId<crate::IdlNormalized>,
343    pub decoder_abi_version: String,
344    pub decoder_engine_id: String,
345    pub decoder_binding_id: String,
346    pub executable_identity: SolanaExecutableIdentityV1,
347}
348
349impl HostedManagedProgramReleaseV2 {
350    pub fn new(fields: HostedManagedProgramReleaseV2Fields) -> Result<Self, HashError> {
351        let release = Self {
352            schema: PROGRAM_RELEASE_SCHEMA_V2.to_string(),
353            release_profile: HOSTED_MANAGED_RELEASE_PROFILE.to_string(),
354            program_id: fields.program_id,
355            program_spec_hash: fields.program_spec_hash,
356            idl_content_hash: fields.idl_content_hash,
357            normalized_idl_hash: fields.normalized_idl_hash,
358            decoder_abi_version: fields.decoder_abi_version,
359            decoder_engine_id: fields.decoder_engine_id,
360            decoder_binding_id: fields.decoder_binding_id,
361            executable_identity: fields.executable_identity,
362        };
363        validate_hosted_managed_program_release_v2(&release)?;
364        Ok(release)
365    }
366
367    pub fn hash(&self) -> Result<HashId<ProgramRelease>, HashError> {
368        validate_hosted_managed_program_release_v2(self)?;
369        hash_jcs(self)
370    }
371}
372
373pub fn parse_hosted_managed_program_release_v2(
374    bytes: &[u8],
375) -> Result<HostedManagedProgramReleaseV2, HashError> {
376    let value = parse_json_bytes_strict(bytes)?;
377    let release: HostedManagedProgramReleaseV2 = serde_json::from_value(value)
378        .map_err(|error| release_projection_error(error.to_string()))?;
379    validate_hosted_managed_program_release_v2(&release)?;
380    Ok(release)
381}
382
383pub fn validate_hosted_managed_program_release_v2(
384    release: &HostedManagedProgramReleaseV2,
385) -> Result<(), HashError> {
386    validate_release_projection(
387        (&release.schema, PROGRAM_RELEASE_SCHEMA_V2),
388        (&release.release_profile, HOSTED_MANAGED_RELEASE_PROFILE),
389        &release.program_id,
390        &release.decoder_engine_id,
391        Some(&release.decoder_abi_version),
392        Some(&release.decoder_binding_id),
393    )?;
394    validate_base58_32(&release.program_id, "programId")?;
395    validate_release_identifier(&release.decoder_abi_version, "decoderAbiVersion", 64)?;
396    validate_release_identifier(&release.decoder_engine_id, "decoderEngineId", 128)?;
397    validate_release_identifier(&release.decoder_binding_id, "decoderBindingId", 128)?;
398    validate_solana_executable_identity_v1(&release.executable_identity)
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
402#[serde(rename_all = "camelCase")]
403pub struct OssGeneratedProgramReleaseV1 {
404    pub schema: String,
405    pub release_profile: String,
406    pub program_id: String,
407    pub program_spec_hash: HashId<ProgramSpec>,
408    pub idl_content_hash: HashId<crate::IdlContent>,
409    pub normalized_idl_hash: HashId<crate::IdlNormalized>,
410    pub decoder_engine_id: String,
411}
412
413impl OssGeneratedProgramReleaseV1 {
414    pub fn new(
415        program_id: impl Into<String>,
416        program_spec_hash: HashId<ProgramSpec>,
417        idl_content_hash: HashId<crate::IdlContent>,
418        normalized_idl_hash: HashId<crate::IdlNormalized>,
419    ) -> Self {
420        Self::with_decoder_engine(
421            program_id,
422            program_spec_hash,
423            idl_content_hash,
424            normalized_idl_hash,
425            OSS_DECODER_ENGINE_ID,
426        )
427    }
428
429    pub fn with_decoder_engine(
430        program_id: impl Into<String>,
431        program_spec_hash: HashId<ProgramSpec>,
432        idl_content_hash: HashId<crate::IdlContent>,
433        normalized_idl_hash: HashId<crate::IdlNormalized>,
434        decoder_engine_id: impl Into<String>,
435    ) -> Self {
436        Self {
437            schema: PROGRAM_RELEASE_SCHEMA_V1.to_string(),
438            release_profile: OSS_GENERATED_RELEASE_PROFILE.to_string(),
439            program_id: program_id.into(),
440            program_spec_hash,
441            idl_content_hash,
442            normalized_idl_hash,
443            decoder_engine_id: decoder_engine_id.into(),
444        }
445    }
446
447    pub fn hash(&self) -> Result<HashId<ProgramRelease>, HashError> {
448        validate_release_projection(
449            (&self.schema, PROGRAM_RELEASE_SCHEMA_V1),
450            (&self.release_profile, OSS_GENERATED_RELEASE_PROFILE),
451            &self.program_id,
452            &self.decoder_engine_id,
453            None,
454            None,
455        )?;
456        hash_jcs(self)
457    }
458}
459
460fn validate_release_projection(
461    schema: (&str, &'static str),
462    release_profile: (&str, &'static str),
463    program_id: &str,
464    decoder_engine_id: &str,
465    decoder_abi_version: Option<&str>,
466    decoder_binding_id: Option<&str>,
467) -> Result<(), HashError> {
468    let (schema, expected_schema) = schema;
469    if schema != expected_schema {
470        return Err(HashError::UnknownVersion(schema.to_string()));
471    }
472    let (release_profile, expected_profile) = release_profile;
473    if release_profile != expected_profile {
474        return Err(HashError::InvalidProjection {
475            projection: "program release",
476            reason: format!("releaseProfile must be '{expected_profile}', not '{release_profile}'"),
477        });
478    }
479    if program_id.is_empty() {
480        return Err(HashError::InvalidProjection {
481            projection: "program release",
482            reason: "programId must not be empty".to_string(),
483        });
484    }
485    if decoder_engine_id.is_empty() {
486        return Err(HashError::InvalidProjection {
487            projection: "program release",
488            reason: "decoderEngineId must not be empty".to_string(),
489        });
490    }
491    if decoder_abi_version.is_some_and(str::is_empty) {
492        return Err(HashError::InvalidProjection {
493            projection: "program release",
494            reason: "decoderAbiVersion must not be empty".to_string(),
495        });
496    }
497    if decoder_binding_id.is_some_and(str::is_empty) {
498        return Err(HashError::InvalidProjection {
499            projection: "program release",
500            reason: "decoderBindingId must not be empty".to_string(),
501        });
502    }
503    Ok(())
504}
505
506fn validate_loader_program_id(
507    actual: &str,
508    expected: &'static str,
509    variant: &'static str,
510) -> Result<(), HashError> {
511    if actual != expected {
512        return Err(release_projection_error(format!(
513            "loaderProgramId for '{variant}' must be '{expected}', not '{actual}'"
514        )));
515    }
516    Ok(())
517}
518
519fn validate_release_identifier(
520    value: &str,
521    field: &str,
522    max_length: usize,
523) -> Result<(), HashError> {
524    if value.is_empty() || value.trim() != value || value.len() > max_length {
525        return Err(release_projection_error(format!(
526            "{field} must be a nonempty, trimmed string of at most {max_length} bytes"
527        )));
528    }
529    Ok(())
530}
531
532fn validate_deployment_slot(value: &str) -> Result<(), HashError> {
533    let canonical = value == "0"
534        || value
535            .strip_prefix(|character: char| ('1'..='9').contains(&character))
536            .is_some_and(|rest| rest.bytes().all(|byte| byte.is_ascii_digit()));
537    if !canonical || value.parse::<u64>().is_err() {
538        return Err(release_projection_error(
539            "deploymentSlot must be a canonical unsigned decimal u64 string".to_string(),
540        ));
541    }
542    Ok(())
543}
544
545fn validate_sha256_digest(value: &str, field: &str) -> Result<(), HashError> {
546    let Some(digest) = value.strip_prefix(SOLANA_EXECUTABLE_PAYLOAD_SHA256_PREFIX) else {
547        return Err(release_projection_error(format!(
548            "{field} must use the sha256:<lowercase-hex> format"
549        )));
550    };
551    if digest.len() != 64
552        || !digest
553            .bytes()
554            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
555    {
556        return Err(release_projection_error(format!(
557            "{field} must use the sha256:<lowercase-hex> format"
558        )));
559    }
560    Ok(())
561}
562
563fn validate_base58_32(value: &str, field: &str) -> Result<(), HashError> {
564    let decoded = bs58::decode(value).into_vec().map_err(|_| {
565        release_projection_error(format!("{field} must be a canonical 32-byte base58 value"))
566    })?;
567    if decoded.len() != 32 || bs58::encode(decoded).into_string() != value {
568        return Err(release_projection_error(format!(
569            "{field} must be a canonical 32-byte base58 value"
570        )));
571    }
572    Ok(())
573}
574
575fn release_projection_error(reason: String) -> HashError {
576    HashError::InvalidProjection {
577        projection: "program release",
578        reason,
579    }
580}