1use sha2::{Digest, Sha256};
14use std::io::{self, Write};
15
16const REQUEST_HASH_DOMAIN_V2: &[u8] = b"appcore.request-hash.v2\0";
17const REQUEST_HASH_PREFIX_V2: &str = "v2:";
18
19#[derive(Debug, Clone)]
21pub struct RequestValidationDetails {
22 pub purpose: String,
24 pub name: String,
26 pub id: String,
28 pub idempotency_key: Option<String>,
30 pub payload: String,
32 pub subject: Option<String>,
34 pub audience: Option<String>,
36}
37
38#[derive(Debug, Clone, Copy)]
40pub enum RequestPayloadRef<'a> {
41 Text(&'a str),
43 Json(&'a serde_json::Value),
45}
46
47#[derive(Debug, Clone, Copy)]
49pub struct RequestValidationDetailsRef<'a> {
50 pub purpose: &'a str,
52 pub name: &'a str,
54 pub id: &'a str,
56 pub idempotency_key: Option<&'a str>,
58 pub payload: RequestPayloadRef<'a>,
60 pub subject: Option<&'a str>,
62 pub audience: Option<&'a str>,
64}
65
66impl RequestValidationDetailsRef<'_> {
67 pub fn to_owned(self) -> Result<RequestValidationDetails, serde_json::Error> {
69 let payload = match self.payload {
70 RequestPayloadRef::Text(payload) => payload.to_string(),
71 RequestPayloadRef::Json(payload) => serde_json::to_string(payload)?,
72 };
73 Ok(RequestValidationDetails {
74 purpose: self.purpose.to_string(),
75 name: self.name.to_string(),
76 id: self.id.to_string(),
77 idempotency_key: self.idempotency_key.map(str::to_string),
78 payload,
79 subject: self.subject.map(str::to_string),
80 audience: self.audience.map(str::to_string),
81 })
82 }
83}
84
85pub fn compute_request_hash(details: &RequestValidationDetails) -> String {
87 let mut hasher = Sha256::new();
88 hasher.update(REQUEST_HASH_DOMAIN_V2);
89 update_required(&mut hasher, 1, &details.purpose);
90 update_required(&mut hasher, 2, &details.name);
91 update_required(&mut hasher, 3, &details.id);
92 update_optional(&mut hasher, 4, details.idempotency_key.as_deref());
93 update_required(&mut hasher, 5, &details.payload);
94 update_optional(&mut hasher, 6, details.subject.as_deref());
95 update_optional(&mut hasher, 7, details.audience.as_deref());
96
97 let digest = hasher.finalize();
98 let mut output = String::with_capacity(REQUEST_HASH_PREFIX_V2.len() + digest.len() * 2);
99 output.push_str(REQUEST_HASH_PREFIX_V2);
100 push_hex(&mut output, &digest);
101 output
102}
103
104pub fn compute_borrowed_request_hash(
106 details: &RequestValidationDetailsRef<'_>,
107) -> Result<String, serde_json::Error> {
108 let mut hasher = Sha256::new();
109 hasher.update(REQUEST_HASH_DOMAIN_V2);
110 update_required(&mut hasher, 1, details.purpose);
111 update_required(&mut hasher, 2, details.name);
112 update_required(&mut hasher, 3, details.id);
113 update_optional(&mut hasher, 4, details.idempotency_key);
114 update_payload(&mut hasher, 5, details.payload)?;
115 update_optional(&mut hasher, 6, details.subject);
116 update_optional(&mut hasher, 7, details.audience);
117
118 let digest = hasher.finalize();
119 let mut output = String::with_capacity(REQUEST_HASH_PREFIX_V2.len() + digest.len() * 2);
120 output.push_str(REQUEST_HASH_PREFIX_V2);
121 push_hex(&mut output, &digest);
122 Ok(output)
123}
124
125fn update_required(hasher: &mut Sha256, tag: u8, value: &str) {
126 hasher.update([tag]);
127 hasher.update((value.len() as u64).to_be_bytes());
128 hasher.update(value.as_bytes());
129}
130
131fn update_optional(hasher: &mut Sha256, tag: u8, value: Option<&str>) {
132 hasher.update([tag]);
133 match value {
134 Some(value) => {
135 hasher.update([1]);
136 hasher.update((value.len() as u64).to_be_bytes());
137 hasher.update(value.as_bytes());
138 }
139 None => hasher.update([0]),
140 }
141}
142
143fn update_payload(
144 hasher: &mut Sha256,
145 tag: u8,
146 payload: RequestPayloadRef<'_>,
147) -> Result<(), serde_json::Error> {
148 match payload {
149 RequestPayloadRef::Text(payload) => update_required(hasher, tag, payload),
150 RequestPayloadRef::Json(payload) => {
151 let mut counter = JsonByteCounter::default();
152 serde_json::to_writer(&mut counter, payload)?;
153 hasher.update([tag]);
154 hasher.update(counter.bytes.to_be_bytes());
155 serde_json::to_writer(JsonHashWriter(hasher), payload)?;
156 }
157 }
158 Ok(())
159}
160
161#[derive(Default)]
162struct JsonByteCounter {
163 bytes: u64,
164}
165
166impl Write for JsonByteCounter {
167 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
168 let length = u64::try_from(bytes.len())
169 .map_err(|_| io::Error::other("request payload length overflowed"))?;
170 self.bytes = self
171 .bytes
172 .checked_add(length)
173 .ok_or_else(|| io::Error::other("request payload length overflowed"))?;
174 Ok(bytes.len())
175 }
176
177 fn flush(&mut self) -> io::Result<()> {
178 Ok(())
179 }
180}
181
182struct JsonHashWriter<'a>(&'a mut Sha256);
183
184impl Write for JsonHashWriter<'_> {
185 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
186 self.0.update(bytes);
187 Ok(bytes.len())
188 }
189
190 fn flush(&mut self) -> io::Result<()> {
191 Ok(())
192 }
193}
194
195fn push_hex(output: &mut String, bytes: &[u8]) {
196 const HEX: &[u8; 16] = b"0123456789abcdef";
197 for byte in bytes {
198 output.push(HEX[(byte >> 4) as usize] as char);
199 output.push(HEX[(byte & 0x0f) as usize] as char);
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::{
206 compute_borrowed_request_hash, compute_request_hash, RequestPayloadRef,
207 RequestValidationDetails, RequestValidationDetailsRef,
208 };
209 use serde_json::json;
210
211 #[test]
212 fn borrowed_text_hash_matches_owned_contract() {
213 let owned = owned_details("hello");
214 let borrowed = borrowed_details(RequestPayloadRef::Text("hello"));
215
216 assert_eq!(
217 compute_borrowed_request_hash(&borrowed).unwrap(),
218 compute_request_hash(&owned)
219 );
220 }
221
222 #[test]
223 fn borrowed_json_hash_matches_canonical_owned_contract() {
224 let payload = json!({"text": "é日本語العربية", "values": [1, 2, 3]});
225 let encoded = serde_json::to_string(&payload).unwrap();
226 let owned = owned_details(&encoded);
227 let borrowed = borrowed_details(RequestPayloadRef::Json(&payload));
228
229 assert_eq!(
230 compute_borrowed_request_hash(&borrowed).unwrap(),
231 compute_request_hash(&owned)
232 );
233 assert_eq!(borrowed.to_owned().unwrap().payload, encoded);
234 }
235
236 fn owned_details(payload: &str) -> RequestValidationDetails {
237 RequestValidationDetails {
238 purpose: "query".to_string(),
239 name: "runtime.status".to_string(),
240 id: "query-1".to_string(),
241 idempotency_key: Some("request-1".to_string()),
242 payload: payload.to_string(),
243 subject: Some("subject-1".to_string()),
244 audience: Some("runtime".to_string()),
245 }
246 }
247
248 fn borrowed_details(payload: RequestPayloadRef<'_>) -> RequestValidationDetailsRef<'_> {
249 RequestValidationDetailsRef {
250 purpose: "query",
251 name: "runtime.status",
252 id: "query-1",
253 idempotency_key: Some("request-1"),
254 payload,
255 subject: Some("subject-1"),
256 audience: Some("runtime"),
257 }
258 }
259}