Skip to main content

storage/bundle/
manifest.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Bundle manifests: what an export carries.
7//!
8//! Specified by `crates/storage/spec/Bundle.lean` ยง8-12.
9
10use std::path::Path;
11
12use crate::error::StorageError;
13use crate::vfs::Vfs;
14
15use super::files::{count_files, count_records, head_of};
16use super::history::Carried;
17
18/// What a bundle claims to contain. Checked on restore, so a truncated
19/// or half-copied bundle is refused rather than restored quietly.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Manifest {
22    pub(crate) records: u64,
23    pub(crate) head: String,
24    pub(crate) cas_objects: u64,
25    pub(crate) files: u64,
26    pub(crate) history: Carried,
27}
28
29impl Manifest {
30    /// The four numbers, counted off the directories that hold the
31    /// thing being described.
32    ///
33    /// One place computes them, so the count a bundle claims and the
34    /// count a restored city is checked against are the same
35    /// measurement of the same kind of directory. Either side counting
36    /// its own way is how the two sides come to disagree about what
37    /// "a file" is.
38    ///
39    /// `files_root` is a city root or a bundle's copy of one, and what
40    /// is counted under it excludes the reserved subtree either way.
41    ///
42    /// # Errors
43    /// Propagates a directory that cannot be walked and a ledger whose
44    /// chain does not verify.
45    pub(crate) fn of(
46        vfs: &dyn Vfs,
47        ledger_dir: &Path,
48        cas_dir: &Path,
49        files_root: &Path,
50    ) -> Result<Manifest, StorageError> {
51        Ok(Manifest {
52            records: count_records(vfs, ledger_dir)?,
53            head: head_of(vfs, ledger_dir)?,
54            cas_objects: count_files(vfs, cas_dir)?,
55            files: count_files(vfs, files_root)?,
56            history: Carried::default(),
57        })
58    }
59
60    /// How many ledger records the bundle holds.
61    #[must_use]
62    pub fn records(&self) -> u64 {
63        self.records
64    }
65
66    /// The chain hash after the last record: the one value that proves
67    /// two ledgers are the same history and not merely the same length.
68    #[must_use]
69    pub fn head(&self) -> &str {
70        &self.head
71    }
72
73    #[must_use]
74    pub fn cas_objects(&self) -> u64 {
75        self.cas_objects
76    }
77
78    /// City files, excluding everything under the reserved prefix.
79    #[must_use]
80    pub fn files(&self) -> u64 {
81        self.files
82    }
83
84    pub(crate) fn to_json(&self) -> String {
85        let mut map = serde_json::Map::new();
86        map.insert("records".to_owned(), self.records.into());
87        map.insert(
88            "head".to_owned(),
89            serde_json::Value::String(self.head.clone()),
90        );
91        map.insert("cas_objects".to_owned(), self.cas_objects.into());
92        map.insert("files".to_owned(), self.files.into());
93        map.insert("history_packs".to_owned(), self.history.packs.into());
94        map.insert("history_refs".to_owned(), self.history.refs.into());
95        serde_json::Value::Object(map).to_string()
96    }
97
98    pub(crate) fn from_json(bytes: &[u8], at: &Path) -> Result<Manifest, StorageError> {
99        let value: serde_json::Value =
100            serde_json::from_slice(bytes).map_err(|err| StorageError::Bundle {
101                op: "read",
102                detail: format!("{}: {err}", at.display()),
103            })?;
104        let number = |key: &str| -> Result<u64, StorageError> {
105            value
106                .get(key)
107                .and_then(serde_json::Value::as_u64)
108                .ok_or_else(|| StorageError::Bundle {
109                    op: "read",
110                    detail: format!("{} has no {key}", at.display()),
111                })
112        };
113        let carried = |key: &str| match value.get(key) {
114            Some(_) => number(key),
115            None => Ok(0),
116        };
117        Ok(Manifest {
118            records: number("records")?,
119            head: value
120                .get("head")
121                .and_then(serde_json::Value::as_str)
122                .ok_or_else(|| StorageError::Bundle {
123                    op: "read",
124                    detail: format!("{} has no head", at.display()),
125                })?
126                .to_owned(),
127            cas_objects: number("cas_objects")?,
128            files: number("files")?,
129            // A v0.0.6 manifest predates these keys and its bundle has
130            // no history directory, which is what zero states.
131            history: Carried {
132                packs: carried("history_packs")?,
133                refs: carried("history_refs")?,
134            },
135        })
136    }
137}
138
139/// The name of the file that states what a bundle holds.
140pub const MANIFEST: &str = "MANIFEST.json";
141
142/// The reserved subtree, as the address grammar spells it.
143pub(crate) use kernel::RESERVED_PREFIX as RESERVED;
144/// A bundle mirrors the city's own names for the two stores it carries,
145/// so a restore lands each one back where it came from. The names
146/// themselves belong to `kernel::layout`, which is where a rename of
147/// either directory happens.
148pub(crate) use kernel::layout::{CAS_DIR as CAS, LEDGER_DIR as LEDGER};
149
150/// The working tree a bundle carries beside the two stores. A bundle's
151/// own name for its own directory, read nowhere else.
152pub(crate) const CITY: &str = "city";
153
154#[cfg(test)]
155#[allow(
156    clippy::unwrap_used,
157    clippy::expect_used,
158    clippy::panic,
159    clippy::indexing_slicing,
160    reason = "test code"
161)]
162mod tests {
163    use super::super::export::Bundle;
164    use super::super::fixture::city_with;
165    use super::*;
166
167    #[test]
168    fn the_manifest_is_byte_stable_across_exports() {
169        let home = tempfile::tempdir().unwrap();
170        city_with(2, home.path());
171        let first = tempfile::tempdir().unwrap();
172        let second = tempfile::tempdir().unwrap();
173        Bundle::export(home.path(), first.path()).unwrap();
174        Bundle::export(home.path(), second.path()).unwrap();
175        assert_eq!(
176            std::fs::read(first.path().join(MANIFEST)).unwrap(),
177            std::fs::read(second.path().join(MANIFEST)).unwrap()
178        );
179        assert_eq!(
180            Bundle::read_manifest(first.path()).unwrap(),
181            Bundle::read_manifest(second.path()).unwrap()
182        );
183    }
184}