1use sha2::{Digest, Sha256};
10use std::collections::BTreeMap;
11
12use crate::vm::artifact::{decode_program, encode_program};
13use crate::vm::program::Program;
14
15const MAGIC: &[u8; 4] = b"HBC1";
16const DIGEST_BYTES: usize = 32;
17const HASH_PREFIX: &str = "sha256:";
18
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
21pub struct SchemaCoordinate {
22 pub id: String,
23 pub hash: String,
24}
25
26impl SchemaCoordinate {
27 pub fn new(
28 id: impl Into<String>,
29 hash: impl Into<String>,
30 ) -> Result<Self, String> {
31 let value = Self {
32 id: id.into(),
33 hash: hash.into(),
34 };
35 validate_coordinate(&value)?;
36 Ok(value)
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct LinkedProgram {
43 pub program: Program,
44 pub schema_links: Vec<SchemaCoordinate>,
45}
46
47pub fn encode_linked_program(
50 program: &Program,
51 schema_links: &[SchemaCoordinate],
52) -> Result<Vec<u8>, String> {
53 let schema_links = canonical_links(schema_links)?;
54 let nested = encode_program(program)?;
55 let mut payload = Writer::default();
56 payload.bytes(&nested)?;
57 payload.len(schema_links.len())?;
58 for coordinate in &schema_links {
59 write_coordinate(&mut payload, coordinate)?;
60 }
61 encode_envelope(&payload.bytes)
62}
63
64pub fn decode_linked_program(bytes: &[u8]) -> Result<LinkedProgram, String> {
67 let payload = decode_envelope(bytes)?;
68 let mut reader = Reader::new(payload);
69 let nested = reader.bytes()?;
70 let program = decode_program(nested)?;
71 let schema_links = reader.many(read_coordinate)?;
72 reader.finish()?;
73 let canonical = canonical_links(&schema_links)?;
74 if canonical != schema_links {
75 return Err("linked bytecode artifact has non-canonical schema link order".into());
76 }
77 Ok(LinkedProgram {
78 program,
79 schema_links,
80 })
81}
82
83fn canonical_links(schema_links: &[SchemaCoordinate]) -> Result<Vec<SchemaCoordinate>, String> {
84 let mut values = schema_links.to_vec();
85 values.sort();
86 let mut identities = BTreeMap::<String, String>::new();
87 for coordinate in &values {
88 validate_coordinate(coordinate)?;
89 let identity = coordinate.id.clone();
90 if let Some(existing) = identities.insert(identity, coordinate.hash.clone()) {
91 if existing == coordinate.hash {
92 return Err("linked bytecode artifact contains duplicate schema coordinate".into());
93 }
94 return Err("linked bytecode artifact contains conflicting schema identity".into());
95 }
96 }
97 Ok(values)
98}
99
100fn validate_coordinate(coordinate: &SchemaCoordinate) -> Result<(), String> {
101 validate_id(&coordinate.id)?;
102 hash_bytes(&coordinate.hash)?;
103 Ok(())
104}
105
106fn validate_id(id: &str) -> Result<(), String> {
107 let mut parts = id.split('/');
108 let namespace = parts.next().unwrap_or_default();
109 let name = parts.next().unwrap_or_default();
110 if namespace.is_empty()
111 || name.is_empty()
112 || parts.next().is_some()
113 || id.starts_with(':')
114 || id.chars().any(char::is_whitespace)
115 {
116 return Err("linked bytecode schema id must be a qualified keyword name".into());
117 }
118 Ok(())
119}
120
121fn hash_bytes(hash: &str) -> Result<[u8; DIGEST_BYTES], String> {
122 let Some(digest) = hash.strip_prefix(HASH_PREFIX) else {
123 return Err("linked bytecode schema hash must use sha256".into());
124 };
125 if digest.len() != DIGEST_BYTES * 2
126 || !digest
127 .bytes()
128 .all(|value| value.is_ascii_digit() || (b'a'..=b'f').contains(&value))
129 {
130 return Err("linked bytecode schema hash must be canonical lowercase hex".into());
131 }
132 let mut output = [0u8; DIGEST_BYTES];
133 for (index, byte) in output.iter_mut().enumerate() {
134 let offset = index * 2;
135 *byte = u8::from_str_radix(&digest[offset..offset + 2], 16)
136 .map_err(|_| "linked bytecode schema hash is invalid")?;
137 }
138 Ok(output)
139}
140
141fn display_hash(bytes: &[u8; DIGEST_BYTES]) -> String {
142 use std::fmt::Write;
143
144 let mut output = String::with_capacity(HASH_PREFIX.len() + DIGEST_BYTES * 2);
145 output.push_str(HASH_PREFIX);
146 for byte in bytes {
147 write!(&mut output, "{byte:02x}").expect("writing to String cannot fail");
148 }
149 output
150}
151
152fn write_coordinate(out: &mut Writer, coordinate: &SchemaCoordinate) -> Result<(), String> {
153 out.string(&coordinate.id)?;
154 out.raw(&hash_bytes(&coordinate.hash)?);
155 Ok(())
156}
157
158fn read_coordinate(reader: &mut Reader<'_>) -> Result<SchemaCoordinate, String> {
159 let id = reader.string()?;
160 let digest: [u8; DIGEST_BYTES] = reader
161 .take(DIGEST_BYTES)?
162 .try_into()
163 .expect("fixed digest length");
164 SchemaCoordinate::new(id, display_hash(&digest))
165}
166
167fn encode_envelope(payload: &[u8]) -> Result<Vec<u8>, String> {
168 let digest = Sha256::digest(payload);
169 let mut output = MAGIC.to_vec();
170 output.extend_from_slice(
171 &u32::try_from(payload.len())
172 .map_err(|_| "linked bytecode artifact is too large")?
173 .to_be_bytes(),
174 );
175 output.extend_from_slice(payload);
176 output.extend_from_slice(&digest);
177 Ok(output)
178}
179
180fn decode_envelope(bytes: &[u8]) -> Result<&[u8], String> {
181 if !bytes.starts_with(MAGIC) {
182 return Err("linked bytecode artifact has invalid magic".into());
183 }
184 if bytes.len() < 8 + DIGEST_BYTES {
185 return Err("linked bytecode artifact is truncated".into());
186 }
187 let payload_len = u32::from_be_bytes(bytes[4..8].try_into().unwrap()) as usize;
188 let payload_end = 8usize
189 .checked_add(payload_len)
190 .ok_or("linked bytecode artifact length overflow")?;
191 if payload_end.checked_add(DIGEST_BYTES) != Some(bytes.len()) {
192 return Err("linked bytecode artifact length mismatch".into());
193 }
194 let payload = &bytes[8..payload_end];
195 if Sha256::digest(payload)[..] != bytes[payload_end..] {
196 return Err("linked bytecode artifact checksum mismatch".into());
197 }
198 Ok(payload)
199}
200
201#[derive(Default)]
202struct Writer {
203 bytes: Vec<u8>,
204}
205
206impl Writer {
207 fn raw(&mut self, value: &[u8]) {
208 self.bytes.extend_from_slice(value);
209 }
210
211 fn u32(&mut self, value: u32) {
212 self.raw(&value.to_be_bytes());
213 }
214
215 fn len(&mut self, value: usize) -> Result<(), String> {
216 self.u32(u32::try_from(value).map_err(|_| "linked bytecode field is too large")?);
217 Ok(())
218 }
219
220 fn bytes(&mut self, value: &[u8]) -> Result<(), String> {
221 self.len(value.len())?;
222 self.raw(value);
223 Ok(())
224 }
225
226 fn string(&mut self, value: &str) -> Result<(), String> {
227 self.bytes(value.as_bytes())
228 }
229}
230
231struct Reader<'a> {
232 bytes: &'a [u8],
233 cursor: usize,
234}
235
236impl<'a> Reader<'a> {
237 fn new(bytes: &'a [u8]) -> Self {
238 Self { bytes, cursor: 0 }
239 }
240
241 fn take(&mut self, size: usize) -> Result<&'a [u8], String> {
242 let end = self
243 .cursor
244 .checked_add(size)
245 .ok_or("linked bytecode artifact length overflow")?;
246 if end > self.bytes.len() {
247 return Err("linked bytecode artifact is truncated".into());
248 }
249 let value = &self.bytes[self.cursor..end];
250 self.cursor = end;
251 Ok(value)
252 }
253
254 fn u32(&mut self) -> Result<u32, String> {
255 Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap()))
256 }
257
258 fn bytes(&mut self) -> Result<&'a [u8], String> {
259 let size = self.u32()? as usize;
260 self.take(size)
261 }
262
263 fn string(&mut self) -> Result<String, String> {
264 String::from_utf8(self.bytes()?.to_vec())
265 .map_err(|_| "linked bytecode artifact contains invalid UTF-8".into())
266 }
267
268 fn many<T>(
269 &mut self,
270 mut read: impl FnMut(&mut Reader<'a>) -> Result<T, String>,
271 ) -> Result<Vec<T>, String> {
272 let size = self.u32()? as usize;
273 let mut values = Vec::with_capacity(size.min(4096));
274 for _ in 0..size {
275 values.push(read(self)?);
276 }
277 Ok(values)
278 }
279
280 fn finish(&self) -> Result<(), String> {
281 if self.cursor == self.bytes.len() {
282 Ok(())
283 } else {
284 Err("linked bytecode artifact has trailing payload bytes".into())
285 }
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::vm::compile_source;
293
294 fn coordinate(id: &str, digit: char) -> SchemaCoordinate {
295 SchemaCoordinate::new(
296 id,
297 format!("sha256:{}", digit.to_string().repeat(64)),
298 )
299 .unwrap()
300 }
301
302 #[test]
303 fn exact_schema_links_round_trip_canonically() {
304 let program = compile_source("(+ 19 23)").unwrap();
305 let account = coordinate("model/account", '2');
306 let identifier = coordinate("model/id", '1');
307 let encoded =
308 encode_linked_program(&program, &[identifier.clone(), account.clone()]).unwrap();
309 assert!(encoded.starts_with(b"HBC1"));
310 let decoded = decode_linked_program(&encoded).unwrap();
311 assert_eq!(decoded.schema_links, vec![account, identifier]);
312 assert_eq!(
313 encode_program(&decoded.program).unwrap(),
314 encode_program(&program).unwrap()
315 );
316 assert_eq!(
317 encode_linked_program(&decoded.program, &decoded.schema_links).unwrap(),
318 encoded
319 );
320 }
321
322 #[test]
323 fn duplicate_and_conflicting_schema_links_are_rejected() {
324 let program = compile_source("42").unwrap();
325 let first = coordinate("model/id", '1');
326 assert_eq!(
327 encode_linked_program(&program, &[first.clone(), first.clone()]).unwrap_err(),
328 "linked bytecode artifact contains duplicate schema coordinate"
329 );
330 let conflicting = coordinate("model/id", '2');
331 assert_eq!(
332 encode_linked_program(&program, &[first, conflicting]).unwrap_err(),
333 "linked bytecode artifact contains conflicting schema identity"
334 );
335 }
336
337 #[test]
338 fn malformed_coordinates_are_rejected_before_encoding() {
339 assert!(
340 SchemaCoordinate::new("unqualified", format!("sha256:{}", "1".repeat(64)))
341 .unwrap_err()
342 .contains("qualified keyword name")
343 );
344 assert!(SchemaCoordinate::new("model/id", "sha256:BAD")
345 .unwrap_err()
346 .contains("canonical lowercase hex"));
347 }
348
349 #[test]
350 fn corruption_is_rejected_before_nested_program_decode() {
351 let program = compile_source("42").unwrap();
352 let mut encoded =
353 encode_linked_program(&program, &[coordinate("model/id", '1')]).unwrap();
354 encoded[12] ^= 1;
355 assert_eq!(
356 decode_linked_program(&encoded).unwrap_err(),
357 "linked bytecode artifact checksum mismatch"
358 );
359 }
360
361 #[test]
362 fn hbc0_is_not_silently_treated_as_a_linked_artifact() {
363 let program = compile_source("42").unwrap();
364 let encoded = encode_program(&program).unwrap();
365 assert_eq!(
366 decode_linked_program(&encoded).unwrap_err(),
367 "linked bytecode artifact has invalid magic"
368 );
369 }
370}