1use crate::{
2 error::{Error, Result},
3 hash::Hash32,
4};
5
6pub const RECIPE_VERSION_V1: u8 = 1;
7pub const RECIPE_VERSION_V2: u8 = 2;
8pub const RECIPE_VERSION_V3: u8 = 3;
9
10type RecipeChunkList = Vec<(Hash32, u32)>;
11type ParsedChunks = (RecipeChunkList, u64, usize);
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct RecipeData {
15 pub version: u8,
16 pub original_file_size: u64,
17 pub original_file_hash: Hash32,
18 pub transform_id: u16,
19 pub transform_version: u16,
20 pub chunks: RecipeChunkList,
21 pub stored_stream_size: u64,
22}
23
24pub fn build_recipe(
25 original_file_size: u64,
26 chunks: &[(Hash32, u32)],
27 original_file_hash: Hash32,
28 transform_id: u16,
29 transform_version: u16,
30) -> Vec<u8> {
31 let mut buf = Vec::with_capacity(1 + 8 + 32 + 2 + 2 + 4 + chunks.len() * (32 + 4));
32 buf.push(RECIPE_VERSION_V3);
33 buf.extend_from_slice(&original_file_size.to_le_bytes());
34 buf.extend_from_slice(original_file_hash.as_bytes());
35 buf.extend_from_slice(&transform_id.to_le_bytes());
36 buf.extend_from_slice(&transform_version.to_le_bytes());
37 buf.extend_from_slice(&(chunks.len() as u32).to_le_bytes());
38 for (hash, len) in chunks {
39 buf.extend_from_slice(hash.as_bytes());
40 buf.extend_from_slice(&len.to_le_bytes());
41 }
42 buf
43}
44
45pub fn parse_recipe(bytes: &[u8]) -> Result<RecipeData> {
46 if bytes.is_empty() {
47 return Err(Error::Other("empty recipe".into()));
48 }
49
50 let version = bytes[0];
51 let offset = 1;
52 match version {
53 RECIPE_VERSION_V1 | RECIPE_VERSION_V2 => parse_recipe_v1(bytes, offset, version),
54 RECIPE_VERSION_V3 => parse_recipe_v3(bytes, offset, version),
55 _ => Err(Error::Other(format!("unexpected recipe version {version}"))),
56 }
57}
58
59pub fn parse_recipe_checked(bytes: &[u8], expected_hash: &Hash32) -> Result<RecipeData> {
60 if Hash32::sha3_256(bytes) != *expected_hash {
61 return Err(Error::Other(format!("recipe hash mismatch for {expected_hash}")));
62 }
63 parse_recipe(bytes)
64}
65
66pub(crate) fn preflight_recipe_limits(bytes: &[u8]) -> Result<(u64, u32)> {
67 let version = *bytes.first().ok_or_else(|| Error::Other("empty recipe".into()))?;
68 let original_file_size = u64::from_le_bytes(
69 bytes
70 .get(1..9)
71 .ok_or_else(|| Error::Other("recipe too short".into()))?
72 .try_into()
73 .expect("the slice length is fixed"),
74 );
75 let chunk_count_offset = match version {
76 RECIPE_VERSION_V1 | RECIPE_VERSION_V2 => 9,
77 RECIPE_VERSION_V3 => 45,
78 _ => return Err(Error::Other(format!("unexpected recipe version {version}"))),
79 };
80 let chunk_count = u32::from_le_bytes(
81 bytes
82 .get(chunk_count_offset..chunk_count_offset + 4)
83 .ok_or_else(|| Error::Other("recipe too short".into()))?
84 .try_into()
85 .expect("the slice length is fixed"),
86 );
87 Ok((original_file_size, chunk_count))
88}
89
90fn parse_recipe_v1(bytes: &[u8], mut offset: usize, version: u8) -> Result<RecipeData> {
91 if bytes.len() < offset + 8 + 4 + 32 {
92 return Err(Error::Other("recipe too short".into()));
93 }
94
95 let original_file_size = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap());
96 offset += 8;
97 let chunk_count = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
98 offset += 4;
99 let (chunks, stored_stream_size, mut offset) = parse_chunks(bytes, offset, chunk_count)?;
100 if bytes.len() < offset + 32 {
101 return Err(Error::Other("recipe missing file hash".into()));
102 }
103 let original_file_hash = Hash32::from_bytes(&bytes[offset..offset + 32])?;
104 offset += 32;
105 if offset != bytes.len() {
106 return Err(Error::Other("recipe has trailing bytes".into()));
107 }
108
109 Ok(RecipeData {
110 version,
111 original_file_size,
112 original_file_hash,
113 transform_id: 0,
114 transform_version: 0,
115 chunks,
116 stored_stream_size,
117 })
118}
119
120fn parse_recipe_v3(bytes: &[u8], mut offset: usize, version: u8) -> Result<RecipeData> {
121 if bytes.len() < offset + 8 + 32 + 2 + 2 + 4 {
122 return Err(Error::Other("recipe too short".into()));
123 }
124
125 let original_file_size = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap());
126 offset += 8;
127 let original_file_hash = Hash32::from_bytes(&bytes[offset..offset + 32])?;
128 offset += 32;
129 let transform_id = u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap());
130 offset += 2;
131 let transform_version = u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap());
132 offset += 2;
133 let chunk_count = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
134 offset += 4;
135 let (chunks, stored_stream_size, offset) = parse_chunks(bytes, offset, chunk_count)?;
136 if offset != bytes.len() {
137 return Err(Error::Other("recipe has trailing bytes".into()));
138 }
139
140 Ok(RecipeData {
141 version,
142 original_file_size,
143 original_file_hash,
144 transform_id,
145 transform_version,
146 chunks,
147 stored_stream_size,
148 })
149}
150
151fn parse_chunks(bytes: &[u8], mut offset: usize, chunk_count: u32) -> Result<ParsedChunks> {
152 let mut chunks = Vec::with_capacity(chunk_count as usize);
153 let mut total = 0u64;
154 for _ in 0..chunk_count {
155 if bytes.len() < offset + 32 + 4 {
156 return Err(Error::Other("recipe truncated".into()));
157 }
158 let hash = Hash32::from_bytes(&bytes[offset..offset + 32])?;
159 offset += 32;
160 let len = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
161 offset += 4;
162 total += len as u64;
163 chunks.push((hash, len));
164 }
165 Ok((chunks, total, offset))
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 fn build_recipe_v1(file_size: u64, chunks: &[(Hash32, u32)], file_hash: Hash32) -> Vec<u8> {
173 let mut buf = Vec::with_capacity(1 + 8 + 4 + chunks.len() * (32 + 4) + 32);
174 buf.push(RECIPE_VERSION_V1);
175 buf.extend_from_slice(&file_size.to_le_bytes());
176 buf.extend_from_slice(&(chunks.len() as u32).to_le_bytes());
177 for (hash, len) in chunks {
178 buf.extend_from_slice(hash.as_bytes());
179 buf.extend_from_slice(&len.to_le_bytes());
180 }
181 buf.extend_from_slice(file_hash.as_bytes());
182 buf
183 }
184
185 #[test]
186 fn recipe_v3_roundtrip_preserves_transform_metadata() {
187 let chunk_a = Hash32::sha3_256(b"chunk-a");
188 let chunk_b = Hash32::sha3_256(b"chunk-b");
189 let file_hash = Hash32::sha3_256(b"file");
190 let bytes = build_recipe(42, &[(chunk_a, 5), (chunk_b, 7)], file_hash, 9, 3);
191
192 let parsed = parse_recipe(&bytes).unwrap();
193 assert_eq!(parsed.version, RECIPE_VERSION_V3);
194 assert_eq!(parsed.original_file_size, 42);
195 assert_eq!(parsed.original_file_hash, file_hash);
196 assert_eq!(parsed.transform_id, 9);
197 assert_eq!(parsed.transform_version, 3);
198 assert_eq!(parsed.chunks, vec![(chunk_a, 5), (chunk_b, 7)]);
199 assert_eq!(parsed.stored_stream_size, 12);
200 }
201
202 #[test]
203 fn recipe_v1_roundtrip_uses_legacy_shape() {
204 let chunk = Hash32::sha3_256(b"legacy-chunk");
205 let file_hash = Hash32::sha3_256(b"legacy-file");
206 let bytes = build_recipe_v1(11, &[(chunk, 11)], file_hash);
207
208 let parsed = parse_recipe(&bytes).unwrap();
209 assert_eq!(parsed.version, RECIPE_VERSION_V1);
210 assert_eq!(parsed.original_file_size, 11);
211 assert_eq!(parsed.original_file_hash, file_hash);
212 assert_eq!(parsed.transform_id, 0);
213 assert_eq!(parsed.transform_version, 0);
214 assert_eq!(parsed.chunks, vec![(chunk, 11)]);
215 assert_eq!(parsed.stored_stream_size, 11);
216 }
217
218 #[test]
219 fn recipe_hash_is_checked_before_parse() {
220 let chunk = Hash32::sha3_256(b"checked-chunk");
221 let file_hash = Hash32::sha3_256(b"checked-file");
222 let bytes = build_recipe(8, &[(chunk, 8)], file_hash, 0, 0);
223 let expected_hash = Hash32::sha3_256(&bytes);
224
225 let parsed = parse_recipe_checked(&bytes, &expected_hash).unwrap();
226 assert_eq!(parsed.original_file_hash, file_hash);
227
228 let error = parse_recipe_checked(&bytes, &Hash32::sha3_256(b"wrong")).unwrap_err();
229 assert!(error.to_string().contains("recipe hash mismatch"));
230 }
231}