Skip to main content

arete_hash/
projection.rs

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