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