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