Skip to main content

storage/bundle/
export.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//! Bundles: export, manifest, restore.
7
8use std::path::Path;
9
10use crate::alias::WriteTarget;
11use crate::error::{StorageError, io_err};
12use crate::real_fs::RealFs;
13use crate::vfs::Vfs;
14
15use super::files::{
16    copy_city_files, copy_tree, count_files, count_records, head_of, only_city_files, walk,
17};
18use super::history::{self, Carried, History};
19use super::landing::{Bits, land};
20use super::manifest::{CAS, CITY, LEDGER, MANIFEST, Manifest};
21
22/// One count taken on the city against the same count taken on the
23/// bundle.
24fn agree(what: &str, source: u64, bundle: u64) -> Result<(), StorageError> {
25    if source == bundle {
26        return Ok(());
27    }
28    Err(StorageError::Bundle {
29        op: "export",
30        detail: format!("the city holds {source} {what}(s) and the bundle holds {bundle}"),
31    })
32}
33
34/// Export and restore. A namespace rather than a value: neither
35/// direction holds state between calls.
36pub struct Bundle;
37
38impl Bundle {
39    /// Writes a bundle of `city_root` into `dest`.
40    ///
41    /// # Errors
42    /// Propagates read and write failures, naming the path; refuses a
43    /// city whose ledger cannot be read, because a bundle of an
44    /// unreadable history is a backup of nothing.
45    pub fn export(city_root: &Path, dest: &Path) -> Result<Manifest, StorageError> {
46        Bundle::export_with(Box::new(RealFs::new()), city_root, dest)
47    }
48
49    pub(crate) fn export_with(
50        mut vfs: Box<dyn Vfs>,
51        city_root: &Path,
52        dest: &Path,
53    ) -> Result<Manifest, StorageError> {
54        let layout = kernel::layout::CityLayout::new(city_root);
55        let ledger_dir = layout.ledger();
56        let cas_dir = layout.cas();
57        let (dest_ledger, dest_cas, dest_city) =
58            (dest.join(LEDGER), dest.join(CAS), dest.join(CITY));
59        let ledger_files = copy_tree(vfs.as_mut(), dest, &ledger_dir, &dest_ledger)?;
60        let cas_copied = copy_tree(vfs.as_mut(), dest, &cas_dir, &dest_cas)?;
61        let city_copied = copy_city_files(vfs.as_mut(), dest, city_root, &dest_city)?;
62        history::export(vfs.as_mut(), city_root, dest)?;
63        // Every number in the manifest is read back from the bundle, so
64        // the manifest states what a reader of the bundle will find.
65        let manifest = Manifest {
66            history: Carried::of(vfs.as_ref(), dest)?,
67            ..Manifest::of(vfs.as_ref(), &dest_ledger, &dest_cas, &dest_city)?
68        };
69        // A manifest read only from the bundle certifies itself: a copy
70        // that silently dropped half the city agrees with its own
71        // manifest. The source side is therefore counted separately and
72        // compared field by field, and a disagreement fails the export
73        // rather than producing a backup that is short.
74        agree(
75            "ledger file",
76            ledger_files,
77            count_files(vfs.as_ref(), &dest_ledger)?,
78        )?;
79        agree("cas object", cas_copied, manifest.cas_objects)?;
80        agree("city file", city_copied, manifest.files)?;
81        agree(
82            "ledger record",
83            count_records(vfs.as_ref(), &ledger_dir)?,
84            manifest.records,
85        )?;
86        let source_head = head_of(vfs.as_ref(), &ledger_dir)?;
87        if source_head != manifest.head {
88            return Err(StorageError::Bundle {
89                op: "export",
90                detail: format!(
91                    "the city's history ends {source_head} and the bundle's ends {}",
92                    manifest.head
93                ),
94            });
95        }
96        let target = WriteTarget::within("write a bundle manifest", dest, &dest.join(MANIFEST))?;
97        land(
98            vfs.as_mut(),
99            target,
100            manifest.to_json().as_bytes(),
101            Bits::OfReplaced,
102        )?;
103        Ok(manifest)
104    }
105
106    /// Reads a bundle's manifest without restoring it.
107    ///
108    /// # Errors
109    /// Refuses a directory with no readable manifest.
110    pub fn read_manifest(bundle: &Path) -> Result<Manifest, StorageError> {
111        let vfs = RealFs::new();
112        let at = bundle.join(MANIFEST);
113        let bytes = vfs
114            .read(&at)
115            .map_err(io_err("read a bundle manifest", &at))?;
116        Manifest::from_json(&bytes, &at)
117    }
118
119    /// Restores a bundle into `city_root`, which must not already hold a
120    /// ledger.
121    ///
122    /// Restoring verifies: the chain is walked, and the record count and
123    /// head hash are compared against the manifest. A bundle that copied
124    /// short is refused here rather than becoming a city that is quietly
125    /// missing its last hour.
126    ///
127    /// # Errors
128    /// Refuses an occupied city root, a bundle without a manifest, and
129    /// any disagreement between the manifest and what was restored.
130    pub fn restore(bundle: &Path, city_root: &Path) -> Result<Manifest, StorageError> {
131        Bundle::restore_with(Box::new(RealFs::new()), bundle, city_root)
132    }
133
134    pub(crate) fn restore_with(
135        mut vfs: Box<dyn Vfs>,
136        bundle: &Path,
137        city_root: &Path,
138    ) -> Result<Manifest, StorageError> {
139        let mut claimed = {
140            let at = bundle.join(MANIFEST);
141            let bytes = vfs
142                .read(&at)
143                .map_err(io_err("read a bundle manifest", &at))?;
144            Manifest::from_json(&bytes, &at)?
145        };
146        // A bundle carries city files and nothing else: git metadata in
147        // one is forged, and restoring it would plant hooks. Refused
148        // before anything is copied, all or nothing (`crates/storage/spec/Bundle.lean` ยง8-12),
149        // except the repository a v0.0.6 export carried whole, whose
150        // files that manifest counted and which lands as history.
151        let repository = only_city_files(vfs.as_ref(), &bundle.join(CITY))?;
152        claimed.files = claimed.files.saturating_sub(repository);
153        let layout = kernel::layout::CityLayout::new(city_root);
154        let ledger_dir = layout.ledger();
155        let cas_dir = layout.cas();
156        if !walk(vfs.as_ref(), &ledger_dir)?.is_empty() {
157            return Err(StorageError::Bundle {
158                op: "restore",
159                detail: format!("{} already holds a ledger", ledger_dir.display()),
160            });
161        }
162        // Counted off the bundle before anything is copied: a pack that
163        // went missing is refused while the city root is still empty.
164        let carried = Carried::of(vfs.as_ref(), bundle)?;
165        if carried != claimed.history {
166            return Err(StorageError::Bundle {
167                op: "restore",
168                detail: format!(
169                    "the bundle claims {} history pack(s) and {} ref(s), and holds {} and {}",
170                    claimed.history.packs, claimed.history.refs, carried.packs, carried.refs
171                ),
172            });
173        }
174        let history = History::read(vfs.as_ref(), bundle, city_root)?;
175        copy_tree(vfs.as_mut(), city_root, &bundle.join(LEDGER), &ledger_dir)?;
176        copy_tree(vfs.as_mut(), city_root, &bundle.join(CAS), &cas_dir)?;
177        copy_city_files(vfs.as_mut(), city_root, &bundle.join(CITY), city_root)?;
178        if let Some(history) = history {
179            history.land(city_root)?;
180        }
181
182        // All four numbers, because a bundle that lost its objects has
183        // the history that points at them: the chain verifies, every
184        // Locator resolves to nothing, and only the object count says
185        // so.
186        let restored = Manifest {
187            history: carried,
188            ..Manifest::of(vfs.as_ref(), &ledger_dir, &cas_dir, city_root)?
189        };
190        if restored != claimed {
191            return Err(StorageError::Bundle {
192                op: "restore",
193                detail: format!(
194                    "the bundle claims {} record(s) ending {} with {} cas object(s) and {} file(s), \
195                     and {} record(s) ending {} with {} cas object(s) and {} file(s) arrived",
196                    claimed.records,
197                    claimed.head,
198                    claimed.cas_objects,
199                    claimed.files,
200                    restored.records,
201                    restored.head,
202                    restored.cas_objects,
203                    restored.files
204                ),
205            });
206        }
207        Ok(restored)
208    }
209}
210
211#[cfg(test)]
212#[allow(
213    clippy::unwrap_used,
214    clippy::expect_used,
215    clippy::panic,
216    clippy::indexing_slicing,
217    reason = "test code"
218)]
219mod tests;
220
221#[cfg(test)]
222#[allow(
223    clippy::unwrap_used,
224    clippy::expect_used,
225    clippy::panic,
226    clippy::indexing_slicing,
227    reason = "test code"
228)]
229mod history_tests;