Skip to main content

storage/bundle/
files.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 files: walking, counting, copying.
7
8use std::collections::BTreeMap;
9use std::io;
10use std::path::{Path, PathBuf};
11
12use kernel::ledger::chain_hash;
13use kernel::{EventRecord, GENESIS_PREV, Seq};
14
15use crate::alias::WriteTarget;
16use crate::error::{StorageError, io_err};
17use crate::jsonl::JsonlLedger;
18use crate::vfs::Vfs;
19
20use super::landing::{Bits, is_staging_name, land};
21
22use super::manifest::RESERVED;
23
24pub(crate) fn head_of(vfs: &dyn Vfs, ledger_dir: &Path) -> Result<String, StorageError> {
25    let mut prev = GENESIS_PREV;
26    let mut expected = Seq::FIRST;
27    for line in read_lines(vfs, ledger_dir)? {
28        let record = EventRecord::parse_line(&line).map_err(|err| StorageError::Bundle {
29            op: "verify",
30            detail: err.to_string(),
31        })?;
32        if record.seq() != expected || record.prev() != prev {
33            return Err(StorageError::Bundle {
34                op: "verify",
35                detail: format!(
36                    "record {} does not continue the chain",
37                    record.seq().value()
38                ),
39            });
40        }
41        prev = chain_hash(&line);
42        expected = expected.next().map_err(|err| StorageError::Bundle {
43            op: "verify",
44            detail: err.to_string(),
45        })?;
46    }
47    Ok(prev.to_string())
48}
49
50pub(crate) fn count_records(vfs: &dyn Vfs, ledger_dir: &Path) -> Result<u64, StorageError> {
51    let lines = read_lines(vfs, ledger_dir)?;
52    u64::try_from(lines.len()).map_err(|_| StorageError::Bundle {
53        op: "count",
54        detail: "more records than a count can hold".to_owned(),
55    })
56}
57
58pub(crate) fn read_lines(vfs: &dyn Vfs, ledger_dir: &Path) -> Result<Vec<Vec<u8>>, StorageError> {
59    let mut out = Vec::new();
60    for path in walk(vfs, ledger_dir)? {
61        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
62            continue;
63        }
64        let bytes = vfs
65            .read(&path)
66            .map_err(io_err("read a bundle file", &path))?;
67        for line in bytes.split(|byte| *byte == b'\n') {
68            if !line.is_empty() {
69                out.push(line.to_vec());
70            }
71        }
72    }
73    Ok(out)
74}
75
76/// Every file under `root`, at any depth, in a deterministic order.
77///
78/// A directory that cannot be listed stops the walk with its own
79/// failure: a subdirectory silently contributing zero files is how a
80/// bundle comes out short and agrees with itself about it. So does an
81/// alias: walking one would read through it, and a copy that followed a
82/// link would carry bytes no name in the bundle accounts for
83/// (`crates/storage/spec/Alias.lean` §8-25).
84///
85/// An explicit worklist rather than recursion: a city's depth is not
86/// this module's to assume, and a stack overflow is not catchable.
87pub(crate) fn walk(vfs: &dyn Vfs, root: &Path) -> Result<Vec<PathBuf>, StorageError> {
88    let mut found = Vec::new();
89    let mut pending = vec![root.to_path_buf()];
90    while let Some(dir) = pending.pop() {
91        let Some(files) = present(vfs.list(&dir), &dir)? else {
92            continue;
93        };
94        for file in &files {
95            refuse_alias(file)?;
96        }
97        found.extend(files);
98        if let Some(dirs) = present(vfs.list_dirs(&dir), &dir)? {
99            for sub in &dirs {
100                refuse_alias(sub)?;
101            }
102            pending.extend(dirs);
103        }
104    }
105    found.sort();
106    Ok(found)
107}
108
109/// One entry against the alias rule, refused whole.
110fn refuse_alias(path: &Path) -> Result<(), StorageError> {
111    match crate::alias::kind_at(path)? {
112        Some(kind) => Err(crate::alias::refused("walk a store or bundle", path, kind)),
113        None => Ok(()),
114    }
115}
116
117/// What travels as a city file: the open tree and each scope's
118/// governance, never git metadata and never the city's own stores.
119///
120/// Root `.sprawling` holds the two stores, and they travel through
121/// faces of their own; a nested one is a building's own rules and is
122/// part of the city. `.git` at any depth is protected metadata, and a
123/// bundle that carried it would plant hooks on restore (`crates/kernel/spec/Address.lean`
124/// §8-73). The names come from kernel's one list; nothing here
125/// re-spells them. A staging file a crashed restore left is half of a
126/// write, and `landing` alone knows its spelling.
127fn travels(relative: &Path) -> bool {
128    !relative.starts_with(RESERVED)
129        && !relative.file_name().is_some_and(is_staging_name)
130        && !relative.components().any(|component| {
131            component
132                .as_os_str()
133                .to_str()
134                .is_some_and(|segment| segment.eq_ignore_ascii_case(kernel::GIT_METADATA))
135        })
136}
137
138/// The restore door's half of the same rule: a bundle may carry city
139/// files and nothing else. Export **selects** what travels; restore
140/// **refuses** what never could - all or nothing, never a skip.
141///
142/// The one entry admitted without travelling is the repository a v0.0.6
143/// export copied whole into `city/.git`: `history` imports its objects
144/// and refs, no file of it is copied, and the count returned is how many
145/// files it holds, which that export's manifest counted as city files.
146///
147/// # Errors
148/// `StorageError::Bundle` naming the first entry that is not a city
149/// file; `StorageError::Alias` from the walk.
150pub(crate) fn only_city_files(vfs: &dyn Vfs, root: &Path) -> Result<u64, StorageError> {
151    let mut repository = 0u64;
152    for path in walk(vfs, root)? {
153        let Ok(relative) = path.strip_prefix(root) else {
154            continue;
155        };
156        if relative
157            .strip_prefix(kernel::GIT_METADATA)
158            .is_ok_and(|inner| !inner.as_os_str().is_empty())
159        {
160            repository = repository.saturating_add(1);
161            continue;
162        }
163        if !travels(relative) {
164            return Err(StorageError::Bundle {
165                op: "restore",
166                detail: format!(
167                    "{} is protected metadata or a staging file, which no bundle may hold",
168                    path.display()
169                ),
170            });
171        }
172    }
173    Ok(repository)
174}
175
176/// A directory that is not there holds nothing, which is an answer; a
177/// directory that refuses to be read is not.
178///
179/// A city with no object store yet has no `cas/` to walk, and export
180/// asks about it before anything has put an object in it.
181fn present(
182    listing: io::Result<Vec<PathBuf>>,
183    dir: &Path,
184) -> Result<Option<Vec<PathBuf>>, StorageError> {
185    match listing {
186        Ok(paths) => Ok(Some(paths)),
187        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
188        Err(err) => Err(io_err("walk a bundle directory", dir)(err)),
189    }
190}
191
192/// Copies every file under `from` into `to`, keeping relative paths;
193/// `root` is the directory the person named, which bounds the alias
194/// check on every write.
195pub(crate) fn copy_tree(
196    vfs: &mut dyn Vfs,
197    root: &Path,
198    from: &Path,
199    to: &Path,
200) -> Result<u64, StorageError> {
201    let mut copied = 0u64;
202    let files = walk(vfs, from)?;
203    if files.is_empty() {
204        return Ok(0);
205    }
206    vfs.create_dir_all(to)
207        .map_err(io_err("make a bundle directory", to))?;
208    for path in files {
209        let Ok(relative) = path.strip_prefix(from) else {
210            continue;
211        };
212        let target = to.join(relative);
213        // Cleared before any directory is made: making a parent through
214        // a link would land a directory inside what the link reaches,
215        // before the file write is refused (`crates/storage/spec/Alias.lean` §8-25).
216        let cleared = WriteTarget::within("copy a bundle file", root, &target)?;
217        if let Some(parent) = target.parent() {
218            vfs.create_dir_all(parent)
219                .map_err(io_err("make a bundle directory", parent))?;
220        }
221        let bytes = vfs
222            .read(&path)
223            .map_err(io_err("read a bundle file", &path))?;
224        land(vfs, cleared, &bytes, Bits::Of(&path))?;
225        copied = copied.saturating_add(1);
226    }
227    Ok(copied)
228}
229
230/// The city's own files: everything outside the reserved prefix, bounded
231/// by `root` as [`copy_tree`] is.
232pub(crate) fn copy_city_files(
233    vfs: &mut dyn Vfs,
234    root: &Path,
235    city_root: &Path,
236    to: &Path,
237) -> Result<u64, StorageError> {
238    let mut copied = 0u64;
239    let mut seen = BTreeMap::new();
240    for path in walk(vfs, city_root)? {
241        let Ok(relative) = path.strip_prefix(city_root) else {
242            continue;
243        };
244        if !travels(relative) {
245            continue;
246        }
247        seen.insert(relative.to_path_buf(), path);
248    }
249    if seen.is_empty() {
250        return Ok(0);
251    }
252    vfs.create_dir_all(to)
253        .map_err(io_err("make a bundle directory", to))?;
254    for (relative, path) in seen {
255        let target = to.join(&relative);
256        // Cleared before any directory is made, for the reason the
257        // sibling walk gives.
258        let cleared = WriteTarget::within("copy a city file", root, &target)?;
259        if let Some(parent) = target.parent() {
260            vfs.create_dir_all(parent)
261                .map_err(io_err("make a bundle directory", parent))?;
262        }
263        let bytes = vfs
264            .read(&path)
265            .map_err(io_err("read a bundle file", &path))?;
266        land(vfs, cleared, &bytes, Bits::Of(&path))?;
267        copied = copied.saturating_add(1);
268    }
269    Ok(copied)
270}
271
272pub(crate) fn count_files(vfs: &dyn Vfs, root: &Path) -> Result<u64, StorageError> {
273    let mut count = 0u64;
274    for path in walk(vfs, root)? {
275        let Ok(relative) = path.strip_prefix(root) else {
276            continue;
277        };
278        if !travels(relative) {
279            continue;
280        }
281        count = count.saturating_add(1);
282    }
283    Ok(count)
284}
285
286/// Opens the restored ledger, so the city is one a writer can continue.
287///
288/// # Errors
289/// Whatever opening reports; a restored city that cannot be opened is
290/// not restored.
291pub fn open_restored(city_root: &Path, now: kernel::TimeMs) -> Result<PathBuf, StorageError> {
292    let dir = kernel::layout::CityLayout::new(city_root).ledger();
293    let (_ledger, _report) = JsonlLedger::open(&dir, now)?;
294    Ok(dir)
295}
296
297#[cfg(test)]
298#[allow(
299    clippy::unwrap_used,
300    clippy::expect_used,
301    clippy::panic,
302    clippy::indexing_slicing,
303    reason = "test code"
304)]
305mod tests {
306    use super::super::export::Bundle;
307    use super::super::fixture::city_with;
308    use super::super::manifest::CITY;
309    use super::*;
310
311    /// The permission a city file carries is part of the file: a
312    /// read-only note (or, on Unix, an executable script) comes back
313    /// the way it left.
314    #[test]
315    fn a_read_only_city_file_comes_back_read_only() {
316        let home = tempfile::tempdir().unwrap();
317        city_with(1, home.path());
318        let kept = home.path().join("kept.md");
319        std::fs::write(&kept, b"do not touch").unwrap();
320        let mut bits = std::fs::metadata(&kept).unwrap().permissions();
321        bits.set_readonly(true);
322        std::fs::set_permissions(&kept, bits).unwrap();
323
324        let carried = tempfile::tempdir().unwrap();
325        Bundle::export(home.path(), carried.path()).unwrap();
326        let elsewhere = tempfile::tempdir().unwrap();
327        Bundle::restore(carried.path(), elsewhere.path()).unwrap();
328        let read_only = |at: &Path| std::fs::metadata(at).unwrap().permissions().readonly();
329        assert_eq!(
330            (
331                read_only(&carried.path().join(CITY).join("kept.md")),
332                read_only(&elsewhere.path().join("kept.md")),
333            ),
334            (true, true)
335        );
336    }
337
338    /// A read-only city file already in the bundle is replaced like any
339    /// other, so an export can be repeated over the bundle it made.
340    #[test]
341    fn an_export_repeats_over_a_bundle_holding_a_read_only_file() {
342        let home = tempfile::tempdir().unwrap();
343        city_with(1, home.path());
344        let kept = home.path().join("kept.md");
345        std::fs::write(&kept, b"do not touch").unwrap();
346        let mut bits = std::fs::metadata(&kept).unwrap().permissions();
347        bits.set_readonly(true);
348        std::fs::set_permissions(&kept, bits).unwrap();
349
350        let carried = tempfile::tempdir().unwrap();
351        Bundle::export(home.path(), carried.path()).unwrap();
352        let again = Bundle::export(home.path(), carried.path())
353            .map(|_| ())
354            .map_err(|err| err.to_string());
355        assert_eq!(again, Ok(()));
356    }
357
358    /// The city root is the person's choice, so a link above it is
359    /// where they keep the city rather than a write a run redirected.
360    #[test]
361    fn a_city_kept_under_a_link_exports_and_restores() {
362        let tmp = tempfile::tempdir().unwrap();
363        let real = tmp.path().join("real");
364        std::fs::create_dir_all(&real).unwrap();
365        let via = tmp.path().join("via");
366        if !crate::alias::tests::place_link(false, &real, &via) {
367            return;
368        }
369        let (city, carried, elsewhere) = (via.join("city"), via.join("bk"), via.join("back"));
370        std::fs::create_dir_all(&city).unwrap();
371        std::fs::create_dir_all(&elsewhere).unwrap();
372        city_with(1, &city);
373        let outcome = Bundle::export(&city, &carried)
374            .and_then(|_| Bundle::restore(&carried, &elsewhere))
375            .map(|_| ())
376            .map_err(|err| err.to_string());
377        assert_eq!(outcome, Ok(()));
378    }
379
380    #[test]
381    fn nothing_under_the_reserved_prefix_travels_as_a_city_file() {
382        let home = tempfile::tempdir().unwrap();
383        city_with(1, home.path());
384        // A side index is disposable; carrying one would be a second
385        // statement of what happened.
386        let cache = home.path().join(RESERVED).join("index");
387        std::fs::create_dir_all(&cache).unwrap();
388        std::fs::write(cache.join("ledger.idx"), b"derived").unwrap();
389
390        let carried = tempfile::tempdir().unwrap();
391        Bundle::export(home.path(), carried.path()).unwrap();
392        assert!(!carried.path().join(CITY).join(RESERVED).exists());
393        assert!(!carried.path().join(CITY).join("index").exists());
394    }
395
396    /// A restore cut short leaves its staging file under the city root;
397    /// the next export must not carry it as the person's file.
398    #[test]
399    fn a_staging_file_a_crash_left_does_not_travel() {
400        let home = tempfile::tempdir().unwrap();
401        city_with(1, home.path());
402        std::fs::write(home.path().join("kept.md"), b"whole").unwrap();
403        std::fs::write(home.path().join(".kept.md.part"), b"half").unwrap();
404
405        let carried = tempfile::tempdir().unwrap();
406        Bundle::export(home.path(), carried.path()).unwrap();
407        let city = carried.path().join(CITY);
408        assert_eq!(
409            (
410                city.join("kept.md").exists(),
411                city.join(".kept.md.part").exists()
412            ),
413            (true, false)
414        );
415    }
416}