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
27pub 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#[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#[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#[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}