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