1use anyhow::{bail, Context, Result};
2use sha2::{Digest, Sha256};
3use std::collections::BTreeMap;
4use std::io::{Read, Write};
5use std::path::Path;
6
7use crate::manifest::Manifest;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum SectionKind {
12 Manifest,
13 Crate,
14 Index,
15 Rustup,
16 Dist,
18 Config,
19}
20
21impl SectionKind {
22 pub fn prefix(&self) -> &'static str {
23 match self {
24 SectionKind::Manifest => "manifest.json",
25 SectionKind::Crate => "crates/",
26 SectionKind::Index => "index/",
27 SectionKind::Rustup => "rustup/",
28 SectionKind::Dist => "dist/",
29 SectionKind::Config => "config.toml",
30 }
31 }
32
33 fn from_path(path: &str) -> Self {
34 if path == "manifest.json" {
35 SectionKind::Manifest
36 } else if path.starts_with("crates/") {
37 SectionKind::Crate
38 } else if path.starts_with("index/") {
39 SectionKind::Index
40 } else if path.starts_with("rustup/") {
41 SectionKind::Rustup
42 } else if path.starts_with("dist/") {
43 SectionKind::Dist
44 } else if path == "config.toml" || path.starts_with("config/") {
45 SectionKind::Config
46 } else {
47 SectionKind::Crate }
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct Section {
55 pub kind: SectionKind,
56 pub path: String,
57 pub data: Vec<u8>,
58}
59
60pub struct Bundle {
62 pub manifest: Manifest,
63 pub sections: Vec<Section>,
64}
65
66pub struct BundleBuilder {
68 sections: Vec<Section>,
69}
70
71impl BundleBuilder {
72 pub fn new() -> Self {
73 Self {
74 sections: Vec::new(),
75 }
76 }
77
78 pub fn add_section(&mut self, kind: SectionKind, path: String, data: Vec<u8>) {
79 self.sections.push(Section { kind, path, data });
80 }
81
82 pub fn add_manifest(&mut self, manifest: &Manifest) -> Result<()> {
83 let json = manifest.to_json()?;
84 self.add_section(
85 SectionKind::Manifest,
86 "manifest.json".to_string(),
87 json.into_bytes(),
88 );
89 Ok(())
90 }
91
92 pub fn add_crate_file(&mut self, name: &str, version: &str, data: Vec<u8>) {
93 let path = format!("crates/{}/{}/download", name, version);
94 self.add_section(SectionKind::Crate, path, data);
95 }
96
97 pub fn add_index_entry(&mut self, name: &str, data: Vec<u8>) {
98 let index_path = crate_index_path(name);
99 self.add_section(SectionKind::Index, format!("index/{}", index_path), data);
100 }
101
102 pub fn add_rustup_file(&mut self, target: &str, filename: &str, data: Vec<u8>) {
103 let path = format!("rustup/dist/{}/{}", target, filename);
104 self.add_section(SectionKind::Rustup, path, data);
105 }
106
107 pub fn add_dist_file(&mut self, relative_path: &str, data: Vec<u8>) {
108 let path = format!("dist/{}", relative_path);
109 self.add_section(SectionKind::Dist, path, data);
110 }
111
112 pub fn add_config(&mut self, config_toml: &str) {
113 self.add_section(
114 SectionKind::Config,
115 "config.toml".to_string(),
116 config_toml.as_bytes().to_vec(),
117 );
118 }
119
120 pub fn add_config_file(&mut self, filename: &str, data: Vec<u8>) {
124 self.add_section(
125 SectionKind::Config,
126 format!("config/{}", filename),
127 data,
128 );
129 }
130
131 pub fn write_to_file(&self, path: &Path) -> Result<()> {
143 let raw = self.build_raw()?;
144
145 let file = std::fs::File::create(path)
146 .with_context(|| format!("failed to create bundle at {}", path.display()))?;
147 let mut encoder = brotli::CompressorWriter::new(file, 4096, 6, 22);
148 encoder.write_all(&raw)?;
149 encoder.flush()?;
150 drop(encoder);
151
152 Ok(())
153 }
154
155 fn build_raw(&self) -> Result<Vec<u8>> {
156 let mut buf = Vec::new();
157
158 buf.extend_from_slice(b"FMPKG\x00\x01\x00");
160
161 let count = self.sections.len() as u32;
163 buf.extend_from_slice(&count.to_le_bytes());
164
165 let mut header_size = 0u64;
167 for s in &self.sections {
168 header_size += 4 + s.path.len() as u64 + 8 + 8;
169 }
170
171 let mut data_offset = 8 + 4 + header_size; let mut offsets = Vec::new();
173 for s in &self.sections {
174 offsets.push((data_offset, s.data.len() as u64));
175 data_offset += s.data.len() as u64;
176 }
177
178 for (i, s) in self.sections.iter().enumerate() {
180 let path_bytes = s.path.as_bytes();
181 buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
182 buf.extend_from_slice(path_bytes);
183 buf.extend_from_slice(&offsets[i].0.to_le_bytes());
184 buf.extend_from_slice(&offsets[i].1.to_le_bytes());
185 }
186
187 for s in &self.sections {
189 buf.extend_from_slice(&s.data);
190 }
191
192 Ok(buf)
193 }
194}
195
196pub struct BundleReader;
198
199impl BundleReader {
200 pub fn read_file(path: &Path) -> Result<Bundle> {
202 let compressed = std::fs::read(path)
203 .with_context(|| format!("failed to read bundle at {}", path.display()))?;
204 Self::read_bytes(&compressed)
205 }
206
207 pub fn read_bytes(compressed: &[u8]) -> Result<Bundle> {
209 let mut decompressed = Vec::new();
210 let mut decoder = brotli::Decompressor::new(compressed, 4096);
211 decoder
212 .read_to_end(&mut decompressed)
213 .context("failed to decompress bundle")?;
214
215 Self::parse_raw(&decompressed)
216 }
217
218 fn parse_raw(data: &[u8]) -> Result<Bundle> {
219 if data.len() < 12 {
220 bail!("bundle too small");
221 }
222
223 if &data[0..8] != b"FMPKG\x00\x01\x00" {
225 bail!("invalid bundle magic");
226 }
227
228 let section_count = u32::from_le_bytes(data[8..12].try_into()?) as usize;
229 let mut pos = 12;
230
231 let mut entries = Vec::new();
233 for _ in 0..section_count {
234 if pos + 4 > data.len() {
235 bail!("truncated header");
236 }
237 let path_len = u32::from_le_bytes(data[pos..pos + 4].try_into()?) as usize;
238 pos += 4;
239
240 if pos + path_len > data.len() {
241 bail!("truncated header path");
242 }
243 let path = std::str::from_utf8(&data[pos..pos + path_len])
244 .context("invalid UTF-8 in section path")?
245 .to_string();
246 pos += path_len;
247
248 if pos + 16 > data.len() {
249 bail!("truncated header offsets");
250 }
251 let offset = u64::from_le_bytes(data[pos..pos + 8].try_into()?) as usize;
252 pos += 8;
253 let length = u64::from_le_bytes(data[pos..pos + 8].try_into()?) as usize;
254 pos += 8;
255
256 entries.push((path, offset, length));
257 }
258
259 let mut sections = Vec::new();
261 for (path, offset, length) in &entries {
262 if offset + length > data.len() {
263 bail!(
264 "section '{}' extends past end of bundle (offset={}, len={}, total={})",
265 path,
266 offset,
267 length,
268 data.len()
269 );
270 }
271 let section_data = data[*offset..*offset + *length].to_vec();
272 let kind = SectionKind::from_path(path);
273 sections.push(Section {
274 kind,
275 path: path.clone(),
276 data: section_data,
277 });
278 }
279
280 let manifest_section = sections
282 .iter()
283 .find(|s| s.kind == SectionKind::Manifest)
284 .context("bundle has no manifest")?;
285 let manifest_json =
286 std::str::from_utf8(&manifest_section.data).context("manifest is not valid UTF-8")?;
287 let manifest = Manifest::from_json(manifest_json)?;
288
289 Ok(Bundle { manifest, sections })
290 }
291
292 pub fn verify(bundle: &Bundle) -> Result<()> {
294 bundle.manifest.verify_hash()?;
296
297 let crate_data: BTreeMap<String, &[u8]> = bundle
299 .sections
300 .iter()
301 .filter(|s| s.kind == SectionKind::Crate)
302 .map(|s| (s.path.clone(), s.data.as_slice()))
303 .collect();
304
305 for entry in bundle.manifest.crates.values() {
306 let path = format!("crates/{}/{}/download", entry.name, entry.version);
307 let data = crate_data
308 .get(&path)
309 .ok_or_else(|| anyhow::anyhow!("missing crate file: {}", path))?;
310
311 let mut hasher = Sha256::new();
312 hasher.update(data);
313 let hash = hex::encode(hasher.finalize());
314
315 if hash != entry.sha256 {
316 bail!(
317 "SHA-256 mismatch for {}-{}: expected {}, got {}",
318 entry.name,
319 entry.version,
320 entry.sha256,
321 hash
322 );
323 }
324 }
325
326 Ok(())
327 }
328}
329
330pub fn crate_index_path(name: &str) -> String {
333 match name.len() {
334 1 => format!("1/{}", name),
335 2 => format!("2/{}", name),
336 3 => format!("3/{}/{}", &name[..1], name),
337 _ => format!("{}/{}/{}", &name[..2], &name[2..4], name),
338 }
339}
340
341pub fn pkg_filename() -> String {
343 let now = chrono::Utc::now();
344 format!("{}-crates.pkg", now.format("%Y%m%d-%H%M"))
345}
346
347pub fn sha256_hex(data: &[u8]) -> String {
349 let mut hasher = Sha256::new();
350 hasher.update(data);
351 hex::encode(hasher.finalize())
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::manifest::{BundleType, Manifest};
358
359 #[test]
360 fn test_crate_index_path() {
361 assert_eq!(crate_index_path("a"), "1/a");
362 assert_eq!(crate_index_path("ab"), "2/ab");
363 assert_eq!(crate_index_path("abc"), "3/a/abc");
364 assert_eq!(crate_index_path("tokio"), "to/ki/tokio");
365 assert_eq!(crate_index_path("serde"), "se/rd/serde");
366 }
367
368 #[test]
369 fn test_bundle_roundtrip() {
370 let mut manifest = Manifest::new(
371 BundleType::Full,
372 None,
373 vec!["x86_64-unknown-linux-gnu".into()],
374 "stable".into(),
375 );
376 let crate_data = b"fake crate data";
377 let hash = sha256_hex(crate_data);
378 manifest.add_crate("test-crate".into(), "1.0.0".into(), hash, crate_data.len() as u64);
379 manifest.seal();
380
381 let mut builder = BundleBuilder::new();
382 builder.add_manifest(&manifest).unwrap();
383 builder.add_crate_file("test-crate", "1.0.0", crate_data.to_vec());
384 builder.add_index_entry("test-crate", b"index data".to_vec());
385 builder.add_config("# config");
386
387 let tmp = tempfile::NamedTempFile::new().unwrap();
388 builder.write_to_file(tmp.path()).unwrap();
389
390 let bundle = BundleReader::read_file(tmp.path()).unwrap();
391 assert_eq!(bundle.manifest.crates.len(), 1);
392 assert_eq!(bundle.sections.len(), 4);
393
394 BundleReader::verify(&bundle).unwrap();
395 }
396
397 #[test]
398 fn test_config_file_roundtrip() {
399 let mut manifest = Manifest::new(
400 BundleType::Full,
401 None,
402 vec!["x86_64-unknown-linux-gnu".into()],
403 "stable".into(),
404 );
405 manifest.seal();
406
407 let mut builder = BundleBuilder::new();
408 builder.add_manifest(&manifest).unwrap();
409 builder.add_config_file("frostmirror.toml", b"base_url = \"x\"".to_vec());
410 builder.add_config_file("depends.toml", b"[dependencies]\n".to_vec());
411
412 let tmp = tempfile::NamedTempFile::new().unwrap();
413 builder.write_to_file(tmp.path()).unwrap();
414
415 let bundle = BundleReader::read_file(tmp.path()).unwrap();
416 let config_sections: Vec<_> = bundle
417 .sections
418 .iter()
419 .filter(|s| s.kind == SectionKind::Config)
420 .collect();
421 assert_eq!(config_sections.len(), 2);
422 let paths: std::collections::BTreeSet<_> =
423 config_sections.iter().map(|s| s.path.as_str()).collect();
424 assert!(paths.contains("config/frostmirror.toml"));
425 assert!(paths.contains("config/depends.toml"));
426 }
427}