Skip to main content

arete_hash/
fixture.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::{
7    canonicalize_jcs, hash_jcs, parse_json_bytes_strict, DecoderFixtureSet, HashError, HashId,
8    IdlNormalized,
9};
10
11pub const DECODER_FIXTURE_SCHEMA_V2: &str = "arete.decoder-fixtures/v2";
12pub const DECODER_FIXTURE_PUBLIC_VALUE_DIGEST_PREFIX: &str = "sha256:";
13pub const DECODER_FIXTURE_MAX_CASES: usize = 256;
14pub const DECODER_FIXTURE_MAX_ACCOUNT_BYTES: usize = 1024 * 1024;
15pub const DECODER_FIXTURE_MAX_TOTAL_ACCOUNT_BYTES: usize = 8 * 1024 * 1024;
16pub const DECODER_FIXTURE_ACCOUNT_DECODE_ERROR_CATEGORIES: &[&str] = &[
17    "owner_mismatch",
18    "unknown_account_type",
19    "account_type_mismatch",
20    "ambiguous_account_type",
21    "account_decode_failed",
22];
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct DecoderFixtureSetV2 {
27    pub schema: String,
28    pub program_id: String,
29    pub normalized_idl_hash: HashId<IdlNormalized>,
30    pub decoder_engine_id: String,
31    pub decoder_abi_version: String,
32    pub cases: Vec<DecoderFixtureCaseV2>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37pub struct DecoderFixtureCaseV2 {
38    pub id: String,
39    pub account_type: String,
40    pub owner: String,
41    pub account_data_hex: String,
42    pub expected: DecoderFixtureExpectedV2,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub expected_private_diagnostics: Option<DecoderFixturePrivateDiagnosticsV2>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
49pub enum DecoderFixtureExpectedV2 {
50    Decoded {
51        #[serde(rename = "publicValueDigest")]
52        public_value_digest: String,
53    },
54    Error {
55        category: DecoderFixtureAccountDecodeErrorCategory,
56    },
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum DecoderFixtureAccountDecodeErrorCategory {
62    OwnerMismatch,
63    UnknownAccountType,
64    AccountTypeMismatch,
65    AmbiguousAccountType,
66    AccountDecodeFailed,
67}
68
69impl DecoderFixtureAccountDecodeErrorCategory {
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            Self::OwnerMismatch => "owner_mismatch",
73            Self::UnknownAccountType => "unknown_account_type",
74            Self::AccountTypeMismatch => "account_type_mismatch",
75            Self::AmbiguousAccountType => "ambiguous_account_type",
76            Self::AccountDecodeFailed => "account_decode_failed",
77        }
78    }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase", deny_unknown_fields)]
83pub struct DecoderFixturePrivateDiagnosticsV2 {
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub trailing_bytes: Option<u32>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub candidate_count: Option<u32>,
88}
89
90impl DecoderFixtureSetV2 {
91    pub fn canonical_projection(&self) -> Result<Self, HashError> {
92        validate_decoder_fixture_set_v2(self)?;
93        let mut projection = self.clone();
94        projection
95            .cases
96            .sort_by(|left, right| left.id.cmp(&right.id));
97        Ok(projection)
98    }
99
100    pub fn hash(&self) -> Result<HashId<DecoderFixtureSet>, HashError> {
101        hash_decoder_fixture_set_v2(self)
102    }
103}
104
105pub fn parse_decoder_fixture_set_v2(bytes: &[u8]) -> Result<DecoderFixtureSetV2, HashError> {
106    let value = parse_json_bytes_strict(bytes)?;
107    let fixture: DecoderFixtureSetV2 =
108        serde_json::from_value(value).map_err(|error| projection_error(error.to_string()))?;
109    validate_decoder_fixture_set_v2(&fixture)?;
110    Ok(fixture)
111}
112
113pub fn validate_decoder_fixture_set_v2(fixture: &DecoderFixtureSetV2) -> Result<(), HashError> {
114    if fixture.schema != DECODER_FIXTURE_SCHEMA_V2 {
115        return Err(HashError::UnknownVersion(fixture.schema.clone()));
116    }
117    validate_pubkey(&fixture.program_id, "programId")?;
118    validate_nonempty_identifier(&fixture.decoder_engine_id, "decoderEngineId", 128)?;
119    validate_nonempty_identifier(&fixture.decoder_abi_version, "decoderAbiVersion", 64)?;
120    if fixture.cases.is_empty() || fixture.cases.len() > DECODER_FIXTURE_MAX_CASES {
121        return invalid(format!(
122            "cases must contain between 1 and {DECODER_FIXTURE_MAX_CASES} entries"
123        ));
124    }
125
126    let mut ids = HashSet::with_capacity(fixture.cases.len());
127    let mut total_bytes = 0_usize;
128    for case in &fixture.cases {
129        validate_stable_id(&case.id, "case id", 128)?;
130        if !ids.insert(case.id.as_str()) {
131            return invalid(format!("case id '{}' is duplicated", case.id));
132        }
133        validate_nonempty_identifier(&case.account_type, "accountType", 128)?;
134        validate_pubkey(&case.owner, "owner")?;
135        validate_account_data_hex(&case.account_data_hex)?;
136        let account_bytes = case.account_data_hex.len() / 2;
137        if account_bytes > DECODER_FIXTURE_MAX_ACCOUNT_BYTES {
138            return invalid(format!(
139                "case '{}' accountDataHex exceeds {DECODER_FIXTURE_MAX_ACCOUNT_BYTES} bytes",
140                case.id
141            ));
142        }
143        total_bytes = total_bytes.checked_add(account_bytes).ok_or_else(|| {
144            projection_error("total accountDataHex byte length overflowed".to_string())
145        })?;
146        if total_bytes > DECODER_FIXTURE_MAX_TOTAL_ACCOUNT_BYTES {
147            return invalid(format!(
148                "fixture accountDataHex exceeds {DECODER_FIXTURE_MAX_TOTAL_ACCOUNT_BYTES} total bytes"
149            ));
150        }
151
152        match &case.expected {
153            DecoderFixtureExpectedV2::Decoded {
154                public_value_digest,
155            } => validate_public_value_digest(public_value_digest)?,
156            DecoderFixtureExpectedV2::Error { .. } => {}
157        }
158
159        if let Some(diagnostics) = &case.expected_private_diagnostics {
160            if diagnostics.trailing_bytes.is_none() && diagnostics.candidate_count.is_none() {
161                return invalid(format!(
162                    "case '{}' expectedPrivateDiagnostics must not be empty",
163                    case.id
164                ));
165            }
166            if diagnostics.candidate_count == Some(0) {
167                return invalid(format!(
168                    "case '{}' candidateCount must be greater than zero",
169                    case.id
170                ));
171            }
172        }
173    }
174    Ok(())
175}
176
177pub fn hash_decoder_fixture_set_v2(
178    fixture: &DecoderFixtureSetV2,
179) -> Result<HashId<DecoderFixtureSet>, HashError> {
180    hash_jcs(&fixture.canonical_projection()?)
181}
182
183pub fn digest_decoder_fixture_public_value_v2<T: Serialize>(
184    value: &T,
185) -> Result<String, HashError> {
186    let canonical = canonicalize_jcs(value)?;
187    Ok(format!(
188        "{DECODER_FIXTURE_PUBLIC_VALUE_DIGEST_PREFIX}{}",
189        hex::encode(Sha256::digest(canonical))
190    ))
191}
192
193fn validate_account_data_hex(value: &str) -> Result<(), HashError> {
194    if !value.len().is_multiple_of(2)
195        || !value
196            .as_bytes()
197            .iter()
198            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
199    {
200        return invalid("accountDataHex must contain lowercase hexadecimal byte pairs".to_string());
201    }
202    Ok(())
203}
204
205fn validate_public_value_digest(value: &str) -> Result<(), HashError> {
206    let Some(digest) = value.strip_prefix(DECODER_FIXTURE_PUBLIC_VALUE_DIGEST_PREFIX) else {
207        return invalid("publicValueDigest must use the sha256:<lowercase-hex> format".to_string());
208    };
209    if digest.len() != 64
210        || !digest
211            .as_bytes()
212            .iter()
213            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
214    {
215        return invalid("publicValueDigest must use the sha256:<lowercase-hex> format".to_string());
216    }
217    Ok(())
218}
219
220fn validate_pubkey(value: &str, field: &str) -> Result<(), HashError> {
221    let decoded = bs58::decode(value)
222        .into_vec()
223        .map_err(|_| projection_error(format!("{field} must be a base58 Solana public key")))?;
224    if decoded.len() != 32 || bs58::encode(decoded).into_string() != value {
225        return invalid(format!("{field} must be a base58 Solana public key"));
226    }
227    Ok(())
228}
229
230fn validate_nonempty_identifier(
231    value: &str,
232    field: &str,
233    max_length: usize,
234) -> Result<(), HashError> {
235    if value.is_empty() || value.trim() != value || value.len() > max_length {
236        return invalid(format!(
237            "{field} must be a nonempty, trimmed string of at most {max_length} bytes"
238        ));
239    }
240    Ok(())
241}
242
243fn validate_stable_id(value: &str, field: &str, max_length: usize) -> Result<(), HashError> {
244    if value.is_empty()
245        || value.len() > max_length
246        || !value.as_bytes().iter().enumerate().all(|(index, byte)| {
247            byte.is_ascii_lowercase()
248                || byte.is_ascii_digit()
249                || (index > 0 && matches!(byte, b'-' | b'_' | b'.'))
250        })
251    {
252        return invalid(format!(
253            "{field} must be a lowercase stable identifier of at most {max_length} bytes"
254        ));
255    }
256    Ok(())
257}
258
259fn projection_error(reason: String) -> HashError {
260    HashError::InvalidProjection {
261        projection: "decoder fixture set",
262        reason,
263    }
264}
265
266fn invalid<T>(reason: String) -> Result<T, HashError> {
267    Err(projection_error(reason))
268}