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, 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase", deny_unknown_fields)]
166pub struct SolanaExecutableIdentityV1 {
167    pub schema: String,
168    pub genesis_hash: String,
169    pub loader: SolanaExecutableLoaderV1,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(
174    tag = "kind",
175    rename_all = "kebab-case",
176    rename_all_fields = "camelCase",
177    deny_unknown_fields
178)]
179pub enum SolanaExecutableLoaderV1 {
180    BpfLoaderV2(SolanaBpfLoaderV2IdentityV1),
181    BpfUpgradeableLoader(SolanaBpfUpgradeableLoaderIdentityV1),
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase", deny_unknown_fields)]
186pub struct SolanaBpfLoaderV2IdentityV1 {
187    pub loader_program_id: String,
188    pub executable_payload_sha256: String,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct SolanaBpfUpgradeableLoaderIdentityV1 {
194    pub loader_program_id: String,
195    pub program_data_address: String,
196    pub deployment_slot: String,
197    pub upgrade_authority: SolanaUpgradeAuthorityV1,
198    pub executable_payload_sha256: String,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(
203    tag = "kind",
204    rename_all = "kebab-case",
205    rename_all_fields = "camelCase",
206    deny_unknown_fields
207)]
208pub enum SolanaUpgradeAuthorityV1 {
209    None(SolanaNoUpgradeAuthorityV1),
210    Address(SolanaUpgradeAuthorityAddressV1),
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct SolanaNoUpgradeAuthorityV1 {}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase", deny_unknown_fields)]
219pub struct SolanaUpgradeAuthorityAddressV1 {
220    pub address: String,
221}
222
223impl SolanaExecutableIdentityV1 {
224    pub fn new(
225        genesis_hash: impl Into<String>,
226        loader: SolanaExecutableLoaderV1,
227    ) -> Result<Self, HashError> {
228        let identity = Self {
229            schema: SOLANA_EXECUTABLE_IDENTITY_SCHEMA_V1.to_string(),
230            genesis_hash: genesis_hash.into(),
231            loader,
232        };
233        validate_solana_executable_identity_v1(&identity)?;
234        Ok(identity)
235    }
236}
237
238impl SolanaExecutableLoaderV1 {
239    pub fn bpf_loader_v2(executable_payload_sha256: impl Into<String>) -> Result<Self, HashError> {
240        let loader = Self::BpfLoaderV2(SolanaBpfLoaderV2IdentityV1 {
241            loader_program_id: SOLANA_BPF_LOADER_V2_PROGRAM_ID.to_string(),
242            executable_payload_sha256: executable_payload_sha256.into(),
243        });
244        validate_solana_executable_loader_v1(&loader)?;
245        Ok(loader)
246    }
247
248    pub fn bpf_upgradeable_loader(
249        program_data_address: impl Into<String>,
250        deployment_slot: u64,
251        upgrade_authority: SolanaUpgradeAuthorityV1,
252        executable_payload_sha256: impl Into<String>,
253    ) -> Result<Self, HashError> {
254        let loader = Self::BpfUpgradeableLoader(SolanaBpfUpgradeableLoaderIdentityV1 {
255            loader_program_id: SOLANA_BPF_UPGRADEABLE_LOADER_PROGRAM_ID.to_string(),
256            program_data_address: program_data_address.into(),
257            deployment_slot: deployment_slot.to_string(),
258            upgrade_authority,
259            executable_payload_sha256: executable_payload_sha256.into(),
260        });
261        validate_solana_executable_loader_v1(&loader)?;
262        Ok(loader)
263    }
264}
265
266impl SolanaUpgradeAuthorityV1 {
267    pub const fn none() -> Self {
268        Self::None(SolanaNoUpgradeAuthorityV1 {})
269    }
270
271    pub fn address(address: impl Into<String>) -> Result<Self, HashError> {
272        let address = address.into();
273        validate_base58_32(&address, "upgradeAuthority.address")?;
274        Ok(Self::Address(SolanaUpgradeAuthorityAddressV1 { address }))
275    }
276}
277
278pub fn parse_solana_executable_identity_v1(
279    bytes: &[u8],
280) -> Result<SolanaExecutableIdentityV1, HashError> {
281    let value = parse_json_bytes_strict(bytes)?;
282    let identity: SolanaExecutableIdentityV1 = serde_json::from_value(value)
283        .map_err(|error| release_projection_error(error.to_string()))?;
284    validate_solana_executable_identity_v1(&identity)?;
285    Ok(identity)
286}
287
288pub fn validate_solana_executable_identity_v1(
289    identity: &SolanaExecutableIdentityV1,
290) -> Result<(), HashError> {
291    if identity.schema != SOLANA_EXECUTABLE_IDENTITY_SCHEMA_V1 {
292        return Err(HashError::UnknownVersion(identity.schema.clone()));
293    }
294    validate_base58_32(&identity.genesis_hash, "genesisHash")?;
295    validate_solana_executable_loader_v1(&identity.loader)
296}
297
298fn validate_solana_executable_loader_v1(
299    loader: &SolanaExecutableLoaderV1,
300) -> Result<(), HashError> {
301    match loader {
302        SolanaExecutableLoaderV1::BpfLoaderV2(loader) => {
303            validate_loader_program_id(
304                &loader.loader_program_id,
305                SOLANA_BPF_LOADER_V2_PROGRAM_ID,
306                "bpf-loader-v2",
307            )?;
308            validate_sha256_digest(&loader.executable_payload_sha256, "executablePayloadSha256")
309        }
310        SolanaExecutableLoaderV1::BpfUpgradeableLoader(loader) => {
311            validate_loader_program_id(
312                &loader.loader_program_id,
313                SOLANA_BPF_UPGRADEABLE_LOADER_PROGRAM_ID,
314                "bpf-upgradeable-loader",
315            )?;
316            validate_base58_32(&loader.program_data_address, "programDataAddress")?;
317            validate_deployment_slot(&loader.deployment_slot)?;
318            if let SolanaUpgradeAuthorityV1::Address(authority) = &loader.upgrade_authority {
319                validate_base58_32(&authority.address, "upgradeAuthority.address")?;
320            }
321            validate_sha256_digest(&loader.executable_payload_sha256, "executablePayloadSha256")
322        }
323    }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(rename_all = "camelCase", deny_unknown_fields)]
328pub struct HostedManagedProgramReleaseV2 {
329    pub schema: String,
330    pub release_profile: String,
331    pub program_id: String,
332    pub program_spec_hash: HashId<ProgramSpec>,
333    pub idl_content_hash: HashId<crate::IdlContent>,
334    pub normalized_idl_hash: HashId<crate::IdlNormalized>,
335    pub decoder_abi_version: String,
336    pub decoder_engine_id: String,
337    pub decoder_binding_id: String,
338    pub executable_identity: SolanaExecutableIdentityV1,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct HostedManagedProgramReleaseV2Fields {
343    pub program_id: String,
344    pub program_spec_hash: HashId<ProgramSpec>,
345    pub idl_content_hash: HashId<crate::IdlContent>,
346    pub normalized_idl_hash: HashId<crate::IdlNormalized>,
347    pub decoder_abi_version: String,
348    pub decoder_engine_id: String,
349    pub decoder_binding_id: String,
350    pub executable_identity: SolanaExecutableIdentityV1,
351}
352
353impl HostedManagedProgramReleaseV2 {
354    pub fn new(fields: HostedManagedProgramReleaseV2Fields) -> Result<Self, HashError> {
355        let release = Self {
356            schema: PROGRAM_RELEASE_SCHEMA_V2.to_string(),
357            release_profile: HOSTED_MANAGED_RELEASE_PROFILE.to_string(),
358            program_id: fields.program_id,
359            program_spec_hash: fields.program_spec_hash,
360            idl_content_hash: fields.idl_content_hash,
361            normalized_idl_hash: fields.normalized_idl_hash,
362            decoder_abi_version: fields.decoder_abi_version,
363            decoder_engine_id: fields.decoder_engine_id,
364            decoder_binding_id: fields.decoder_binding_id,
365            executable_identity: fields.executable_identity,
366        };
367        validate_hosted_managed_program_release_v2(&release)?;
368        Ok(release)
369    }
370
371    pub fn hash(&self) -> Result<HashId<ProgramRelease>, HashError> {
372        validate_hosted_managed_program_release_v2(self)?;
373        hash_jcs(self)
374    }
375}
376
377pub fn parse_hosted_managed_program_release_v2(
378    bytes: &[u8],
379) -> Result<HostedManagedProgramReleaseV2, HashError> {
380    let value = parse_json_bytes_strict(bytes)?;
381    let release: HostedManagedProgramReleaseV2 = serde_json::from_value(value)
382        .map_err(|error| release_projection_error(error.to_string()))?;
383    validate_hosted_managed_program_release_v2(&release)?;
384    Ok(release)
385}
386
387pub fn validate_hosted_managed_program_release_v2(
388    release: &HostedManagedProgramReleaseV2,
389) -> Result<(), HashError> {
390    validate_release_projection(
391        (&release.schema, PROGRAM_RELEASE_SCHEMA_V2),
392        (&release.release_profile, HOSTED_MANAGED_RELEASE_PROFILE),
393        &release.program_id,
394        &release.decoder_engine_id,
395        Some(&release.decoder_abi_version),
396        Some(&release.decoder_binding_id),
397    )?;
398    validate_base58_32(&release.program_id, "programId")?;
399    validate_release_identifier(&release.decoder_abi_version, "decoderAbiVersion", 64)?;
400    validate_release_identifier(&release.decoder_engine_id, "decoderEngineId", 128)?;
401    validate_release_identifier(&release.decoder_binding_id, "decoderBindingId", 128)?;
402    validate_solana_executable_identity_v1(&release.executable_identity)
403}
404
405/// Immutable hosted-private release identity.
406///
407/// Ownership, admission, alias, visibility, and observations deliberately do
408/// not participate in this projection. The same exact decoder artifact may be
409/// granted to multiple owners without changing its content identity.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "camelCase", deny_unknown_fields)]
412pub struct HostedPrivateProgramReleaseV3 {
413    pub schema: String,
414    pub release_profile: String,
415    pub program_id: String,
416    pub program_spec_hash: HashId<ProgramSpec>,
417    pub idl_content_hash: HashId<crate::IdlContent>,
418    pub normalized_idl_hash: HashId<crate::IdlNormalized>,
419    pub decoder_abi_version: String,
420    pub decoder_engine_id: String,
421    pub decoder_binding_id: String,
422    pub executable_policy: String,
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub struct HostedPrivateProgramReleaseV3Fields {
427    pub program_id: String,
428    pub program_spec_hash: HashId<ProgramSpec>,
429    pub idl_content_hash: HashId<crate::IdlContent>,
430    pub normalized_idl_hash: HashId<crate::IdlNormalized>,
431    pub decoder_abi_version: String,
432    pub decoder_engine_id: String,
433    pub decoder_binding_id: String,
434}
435
436impl HostedPrivateProgramReleaseV3 {
437    pub fn new(fields: HostedPrivateProgramReleaseV3Fields) -> Result<Self, HashError> {
438        let release = Self {
439            schema: PROGRAM_RELEASE_SCHEMA_V3.to_string(),
440            release_profile: HOSTED_PRIVATE_RELEASE_PROFILE.to_string(),
441            program_id: fields.program_id,
442            program_spec_hash: fields.program_spec_hash,
443            idl_content_hash: fields.idl_content_hash,
444            normalized_idl_hash: fields.normalized_idl_hash,
445            decoder_abi_version: fields.decoder_abi_version,
446            decoder_engine_id: fields.decoder_engine_id,
447            decoder_binding_id: fields.decoder_binding_id,
448            executable_policy: HOSTED_PRIVATE_EXECUTABLE_POLICY.to_string(),
449        };
450        validate_hosted_private_program_release_v3(&release)?;
451        Ok(release)
452    }
453
454    pub fn hash(&self) -> Result<HashId<ProgramRelease>, HashError> {
455        validate_hosted_private_program_release_v3(self)?;
456        hash_jcs(self)
457    }
458}
459
460pub fn parse_hosted_private_program_release_v3(
461    bytes: &[u8],
462) -> Result<HostedPrivateProgramReleaseV3, HashError> {
463    let value = parse_json_bytes_strict(bytes)?;
464    let release: HostedPrivateProgramReleaseV3 = serde_json::from_value(value)
465        .map_err(|error| release_projection_error(error.to_string()))?;
466    validate_hosted_private_program_release_v3(&release)?;
467    Ok(release)
468}
469
470pub fn validate_hosted_private_program_release_v3(
471    release: &HostedPrivateProgramReleaseV3,
472) -> Result<(), HashError> {
473    validate_release_projection(
474        (&release.schema, PROGRAM_RELEASE_SCHEMA_V3),
475        (&release.release_profile, HOSTED_PRIVATE_RELEASE_PROFILE),
476        &release.program_id,
477        &release.decoder_engine_id,
478        Some(&release.decoder_abi_version),
479        Some(&release.decoder_binding_id),
480    )?;
481    validate_base58_32(&release.program_id, "programId")?;
482    validate_release_identifier(&release.decoder_abi_version, "decoderAbiVersion", 64)?;
483    validate_release_identifier(&release.decoder_engine_id, "decoderEngineId", 128)?;
484    DecoderBindingId::new(&release.decoder_binding_id)?;
485    if release.executable_policy != HOSTED_PRIVATE_EXECUTABLE_POLICY {
486        return Err(release_projection_error(format!(
487            "executablePolicy must be '{}', not '{}'",
488            HOSTED_PRIVATE_EXECUTABLE_POLICY, release.executable_policy
489        )));
490    }
491    Ok(())
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
495#[serde(rename_all = "camelCase")]
496pub struct OssGeneratedProgramReleaseV1 {
497    pub schema: String,
498    pub release_profile: String,
499    pub program_id: String,
500    pub program_spec_hash: HashId<ProgramSpec>,
501    pub idl_content_hash: HashId<crate::IdlContent>,
502    pub normalized_idl_hash: HashId<crate::IdlNormalized>,
503    pub decoder_engine_id: String,
504}
505
506impl OssGeneratedProgramReleaseV1 {
507    pub fn new(
508        program_id: impl Into<String>,
509        program_spec_hash: HashId<ProgramSpec>,
510        idl_content_hash: HashId<crate::IdlContent>,
511        normalized_idl_hash: HashId<crate::IdlNormalized>,
512    ) -> Self {
513        Self::with_decoder_engine(
514            program_id,
515            program_spec_hash,
516            idl_content_hash,
517            normalized_idl_hash,
518            OSS_DECODER_ENGINE_ID,
519        )
520    }
521
522    pub fn with_decoder_engine(
523        program_id: impl Into<String>,
524        program_spec_hash: HashId<ProgramSpec>,
525        idl_content_hash: HashId<crate::IdlContent>,
526        normalized_idl_hash: HashId<crate::IdlNormalized>,
527        decoder_engine_id: impl Into<String>,
528    ) -> Self {
529        Self {
530            schema: PROGRAM_RELEASE_SCHEMA_V1.to_string(),
531            release_profile: OSS_GENERATED_RELEASE_PROFILE.to_string(),
532            program_id: program_id.into(),
533            program_spec_hash,
534            idl_content_hash,
535            normalized_idl_hash,
536            decoder_engine_id: decoder_engine_id.into(),
537        }
538    }
539
540    pub fn hash(&self) -> Result<HashId<ProgramRelease>, HashError> {
541        validate_release_projection(
542            (&self.schema, PROGRAM_RELEASE_SCHEMA_V1),
543            (&self.release_profile, OSS_GENERATED_RELEASE_PROFILE),
544            &self.program_id,
545            &self.decoder_engine_id,
546            None,
547            None,
548        )?;
549        hash_jcs(self)
550    }
551}
552
553fn validate_release_projection(
554    schema: (&str, &'static str),
555    release_profile: (&str, &'static str),
556    program_id: &str,
557    decoder_engine_id: &str,
558    decoder_abi_version: Option<&str>,
559    decoder_binding_id: Option<&str>,
560) -> Result<(), HashError> {
561    let (schema, expected_schema) = schema;
562    if schema != expected_schema {
563        return Err(HashError::UnknownVersion(schema.to_string()));
564    }
565    let (release_profile, expected_profile) = release_profile;
566    if release_profile != expected_profile {
567        return Err(HashError::InvalidProjection {
568            projection: "program release",
569            reason: format!("releaseProfile must be '{expected_profile}', not '{release_profile}'"),
570        });
571    }
572    if program_id.is_empty() {
573        return Err(HashError::InvalidProjection {
574            projection: "program release",
575            reason: "programId must not be empty".to_string(),
576        });
577    }
578    if decoder_engine_id.is_empty() {
579        return Err(HashError::InvalidProjection {
580            projection: "program release",
581            reason: "decoderEngineId must not be empty".to_string(),
582        });
583    }
584    if decoder_abi_version.is_some_and(str::is_empty) {
585        return Err(HashError::InvalidProjection {
586            projection: "program release",
587            reason: "decoderAbiVersion must not be empty".to_string(),
588        });
589    }
590    if decoder_binding_id.is_some_and(str::is_empty) {
591        return Err(HashError::InvalidProjection {
592            projection: "program release",
593            reason: "decoderBindingId must not be empty".to_string(),
594        });
595    }
596    Ok(())
597}
598
599fn validate_loader_program_id(
600    actual: &str,
601    expected: &'static str,
602    variant: &'static str,
603) -> Result<(), HashError> {
604    if actual != expected {
605        return Err(release_projection_error(format!(
606            "loaderProgramId for '{variant}' must be '{expected}', not '{actual}'"
607        )));
608    }
609    Ok(())
610}
611
612fn validate_release_identifier(
613    value: &str,
614    field: &str,
615    max_length: usize,
616) -> Result<(), HashError> {
617    if value.is_empty() || value.trim() != value || value.len() > max_length {
618        return Err(release_projection_error(format!(
619            "{field} must be a nonempty, trimmed string of at most {max_length} bytes"
620        )));
621    }
622    Ok(())
623}
624
625fn validate_deployment_slot(value: &str) -> Result<(), HashError> {
626    let canonical = value == "0"
627        || value
628            .strip_prefix(|character: char| ('1'..='9').contains(&character))
629            .is_some_and(|rest| rest.bytes().all(|byte| byte.is_ascii_digit()));
630    if !canonical || value.parse::<u64>().is_err() {
631        return Err(release_projection_error(
632            "deploymentSlot must be a canonical unsigned decimal u64 string".to_string(),
633        ));
634    }
635    Ok(())
636}
637
638fn validate_sha256_digest(value: &str, field: &str) -> Result<(), HashError> {
639    let Some(digest) = value.strip_prefix(SOLANA_EXECUTABLE_PAYLOAD_SHA256_PREFIX) else {
640        return Err(release_projection_error(format!(
641            "{field} must use the sha256:<lowercase-hex> format"
642        )));
643    };
644    if digest.len() != 64
645        || !digest
646            .bytes()
647            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
648    {
649        return Err(release_projection_error(format!(
650            "{field} must use the sha256:<lowercase-hex> format"
651        )));
652    }
653    Ok(())
654}
655
656fn validate_base58_32(value: &str, field: &str) -> Result<(), HashError> {
657    let decoded = bs58::decode(value).into_vec().map_err(|_| {
658        release_projection_error(format!("{field} must be a canonical 32-byte base58 value"))
659    })?;
660    if decoded.len() != 32 || bs58::encode(decoded).into_string() != value {
661        return Err(release_projection_error(format!(
662            "{field} must be a canonical 32-byte base58 value"
663        )));
664    }
665    Ok(())
666}
667
668fn release_projection_error(reason: String) -> HashError {
669    HashError::InvalidProjection {
670        projection: "program release",
671        reason,
672    }
673}