Skip to main content

assetpack_core/
recipe.rs

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}