Skip to main content

frostmirror_core/
bundle.rs

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/// Section kinds in a `.pkg` bundle.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum SectionKind {
12    Manifest,
13    Crate,
14    Index,
15    Rustup,
16    /// Toolchain distribution files (channel manifests + component archives).
17    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 // fallback
48        }
49    }
50}
51
52/// A section within the bundle.
53#[derive(Debug, Clone)]
54pub struct Section {
55    pub kind: SectionKind,
56    pub path: String,
57    pub data: Vec<u8>,
58}
59
60/// High-level `.pkg` bundle representation.
61pub struct Bundle {
62    pub manifest: Manifest,
63    pub sections: Vec<Section>,
64}
65
66/// Builds a `.pkg` bundle file.
67pub 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    /// Add a named config file under the `config/` prefix. Used by snapshot
121    /// exports to bundle multiple files (e.g. `frostmirror.toml`,
122    /// `depends.toml`) so the importer can place them back into the config dir.
123    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    /// Write the bundle to a file, compressed with brotli.
132    ///
133    /// Bundle format (before brotli compression):
134    /// - 8 bytes: magic "FMPKG\x00\x01\x00"
135    /// - 4 bytes: number of sections (u32 LE)
136    /// - For each section in the header table:
137    ///   - 4 bytes: path length (u32 LE)
138    ///   - N bytes: path (UTF-8)
139    ///   - 8 bytes: data offset (u64 LE)
140    ///   - 8 bytes: data length (u64 LE)
141    /// - Raw section data concatenated
142    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        // Magic
159        buf.extend_from_slice(b"FMPKG\x00\x01\x00");
160
161        // Section count
162        let count = self.sections.len() as u32;
163        buf.extend_from_slice(&count.to_le_bytes());
164
165        // Calculate offsets: header comes first, then data
166        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; // magic + count + header
172        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        // Write header table
179        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        // Write section data
188        for s in &self.sections {
189            buf.extend_from_slice(&s.data);
190        }
191
192        Ok(buf)
193    }
194}
195
196/// Reads a `.pkg` bundle file.
197pub struct BundleReader;
198
199impl BundleReader {
200    /// Read and decompress a `.pkg` file, returning all sections.
201    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    /// Read from compressed bytes.
208    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        // Verify magic
224        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        // Read header table
232        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        // Read sections
260        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        // Extract manifest
281        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    /// Verify the integrity of a bundle: check manifest hash and per-crate SHA-256.
293    pub fn verify(bundle: &Bundle) -> Result<()> {
294        // Verify manifest hash
295        bundle.manifest.verify_hash()?;
296
297        // Build a map of crate path → data for SHA-256 checking
298        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
330/// Compute the sparse index path for a crate name.
331/// See: https://doc.rust-lang.org/cargo/reference/registry-index.html
332pub 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
341/// Generate the pkg filename from the current timestamp.
342pub fn pkg_filename() -> String {
343    let now = chrono::Utc::now();
344    format!("{}-crates.pkg", now.format("%Y%m%d-%H%M"))
345}
346
347/// Compute SHA-256 of arbitrary data.
348pub 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}