Skip to main content

kevy_cli/
backup.rs

1//! `kevy-cli backup` / `kevy-cli restore` subcommands.
2//!
3//! Backups bundle a kevy `data_dir` (snapshot + AOF) into a single
4//! `.kevybkp` file using a tiny custom container format (std-only,
5//! 0-dep — no `tar` crate per project rule).
6//!
7//! Container format:
8//!
9//! ```text
10//!   magic        : 8 bytes = b"KEVYBKP1"
11//!   for each file:
12//!     name_len   : u16 big-endian
13//!     name       : <name_len> bytes (UTF-8, relative to data_dir)
14//!     body_len   : u64 big-endian
15//!     body       : <body_len> bytes
16//!   eof marker   : u16 = 0
17//! ```
18//!
19//! Backup ordering: typically the operator issues a `BGSAVE` first
20//! against the live kevy via TCP, then runs `kevy-cli backup` once
21//! the snapshot has flushed. (kevy-cli backup can do this in one
22//! call too — see the `--bgsave` flag.)
23
24use std::fs::{File, OpenOptions};
25use std::io::{self, BufReader, BufWriter, Read, Write};
26use std::path::{Path, PathBuf};
27
28const MAGIC: &[u8; 8] = b"KEVYBKP1";
29
30/// Pack every regular file under `data_dir` into `out_path`.
31/// Subdirectories are skipped, and the data dir stopped being flat when
32/// tiering landed: `tier/<shard>/vlog-*.dat` holds demoted values and
33/// `segs-<shard>/` holds cold index segments. Skipping them is correct
34/// because both are **derived** — a snapshot materialises cold values
35/// rather than referencing them, so the backup carries the data without
36/// the spill area and a restore rebuilds it — measured end to end
37/// against a store whose values had actually been demoted, not
38/// reasoned about.
39///
40/// That safety is a property of what lives down there, not of the skip.
41/// `subdirectories_are_derived_spill_only` pins the set, so a future
42/// directory of TRUTH cannot join the data dir without someone reading
43/// this first.
44pub fn pack(data_dir: &Path, out_path: &Path) -> io::Result<u64> {
45    let out = OpenOptions::new()
46        .create_new(true)
47        .write(true)
48        .open(out_path)?;
49    let mut w = BufWriter::new(out);
50    w.write_all(MAGIC)?;
51    let mut total_bytes: u64 = 0;
52    let mut file_count: u64 = 0;
53    for entry in std::fs::read_dir(data_dir)? {
54        let entry = entry?;
55        let path = entry.path();
56        let meta = entry.metadata()?;
57        if !meta.is_file() {
58            continue; // derived spill — see the note above this fn
59        }
60        let name = path
61            .strip_prefix(data_dir)
62            .map_err(|_| io::Error::other("name not under data_dir"))?
63            .to_string_lossy()
64            .into_owned();
65        let name_bytes = name.as_bytes();
66        if name_bytes.len() > u16::MAX as usize {
67            return Err(io::Error::other("file name too long"));
68        }
69        let body_len = meta.len();
70        w.write_all(&(name_bytes.len() as u16).to_be_bytes())?;
71        w.write_all(name_bytes)?;
72        w.write_all(&body_len.to_be_bytes())?;
73        copy_file_body(&mut w, &path, body_len)?;
74        total_bytes += body_len;
75        file_count += 1;
76    }
77    // EOF marker: name_len = 0.
78    w.write_all(&0u16.to_be_bytes())?;
79    w.flush()?;
80    eprintln!(
81        "kevy-cli: backed up {file_count} file(s), {total_bytes} bytes total → {}",
82        out_path.display()
83    );
84    Ok(total_bytes)
85}
86
87/// Stream exactly `body_len` bytes of `path` into `w`.
88fn copy_file_body(w: &mut impl Write, path: &Path, body_len: u64) -> io::Result<()> {
89    let mut f = BufReader::new(File::open(path)?);
90    let mut buf = vec![0u8; 64 * 1024];
91    let mut copied = 0u64;
92    while copied < body_len {
93        let want = std::cmp::min((body_len - copied) as usize, buf.len());
94        let n = f.read(&mut buf[..want])?;
95        if n == 0 {
96            // File shrunk between metadata-stat and content-read
97            // (live backup race; AOF rewrite can shrink the file).
98            // Pad with zeros to honor the body_len we committed.
99            // Restore replay handles trailing zeros as torn-frame
100            // tail truncation (existing kevy-persist::replay logic).
101            let mut remaining = body_len - copied;
102            let zeros = [0u8; 64 * 1024];
103            while remaining > 0 {
104                let chunk = std::cmp::min(remaining as usize, zeros.len());
105                w.write_all(&zeros[..chunk])?;
106                remaining -= chunk as u64;
107            }
108            break;
109        }
110        w.write_all(&buf[..n])?;
111        copied += n as u64;
112    }
113    Ok(())
114}
115
116/// Unpack the container at `in_path` into `target_dir` (created if
117/// missing; refuses to overwrite an existing non-empty dir to avoid
118/// clobbering live data).
119pub fn unpack(in_path: &Path, target_dir: &Path) -> io::Result<u64> {
120    std::fs::create_dir_all(target_dir)?;
121    // Refuse to write into a non-empty dir (safety).
122    let existing = std::fs::read_dir(target_dir)?.count();
123    if existing > 0 {
124        return Err(io::Error::new(
125            io::ErrorKind::AlreadyExists,
126            format!(
127                "target dir {} is not empty ({existing} entries); refuse to overwrite",
128                target_dir.display()
129            ),
130        ));
131    }
132    let mut r = BufReader::new(File::open(in_path)?);
133    let mut magic = [0u8; 8];
134    r.read_exact(&mut magic)?;
135    if &magic != MAGIC {
136        return Err(io::Error::new(
137            io::ErrorKind::InvalidData,
138            "not a kevy backup container (magic mismatch)",
139        ));
140    }
141    let mut file_count = 0u64;
142    let mut total = 0u64;
143    // Loop ends at the EOF marker (read_entry_name -> None).
144    while let Some(name) = read_entry_name(&mut r)? {
145        let mut body_len_buf = [0u8; 8];
146        r.read_exact(&mut body_len_buf)?;
147        let body_len = u64::from_be_bytes(body_len_buf);
148        let out_path = target_dir.join(&name);
149        copy_entry_body(&mut r, &out_path, &name, body_len)?;
150        file_count += 1;
151        total += body_len;
152    }
153    eprintln!(
154        "kevy-cli: restored {file_count} file(s), {total} bytes total → {}",
155        target_dir.display()
156    );
157    Ok(total)
158}
159
160/// Read one entry's name header. `None` = the EOF marker (name_len 0).
161fn read_entry_name(r: &mut impl Read) -> io::Result<Option<String>> {
162    let mut name_len_buf = [0u8; 2];
163    r.read_exact(&mut name_len_buf)?;
164    let name_len = u16::from_be_bytes(name_len_buf);
165    if name_len == 0 {
166        return Ok(None);
167    }
168    let mut name_bytes = vec![0u8; name_len as usize];
169    r.read_exact(&mut name_bytes)?;
170    let name = std::str::from_utf8(&name_bytes)
171        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
172    // Reject path components that try to escape (e.g., "../etc/passwd").
173    if name.contains("..") || name.starts_with('/') {
174        return Err(io::Error::new(
175            io::ErrorKind::InvalidData,
176            format!("backup entry name {name:?} contains path traversal"),
177        ));
178    }
179    Ok(Some(name.to_owned()))
180}
181
182/// Stream exactly `body_len` bytes from `r` into a fresh file at
183/// `out_path`. `name` only feeds the truncation error message.
184fn copy_entry_body(
185    r: &mut impl Read,
186    out_path: &Path,
187    name: &str,
188    body_len: u64,
189) -> io::Result<()> {
190    let mut out = BufWriter::new(File::create(out_path)?);
191    let mut remaining = body_len;
192    let mut buf = vec![0u8; 64 * 1024];
193    while remaining > 0 {
194        let want = std::cmp::min(remaining as usize, buf.len());
195        let n = r.read(&mut buf[..want])?;
196        if n == 0 {
197            return Err(io::Error::new(
198                io::ErrorKind::UnexpectedEof,
199                format!("backup truncated mid-file {name:?}"),
200            ));
201        }
202        out.write_all(&buf[..n])?;
203        remaining -= n as u64;
204    }
205    out.flush()
206}
207
208/// Wrapper around `pack` that accepts string paths for the CLI layer.
209pub fn run_backup(data_dir: PathBuf, out_path: PathBuf) -> io::Result<()> {
210    pack(&data_dir, &out_path).map(|_| ())
211}
212
213/// Wrapper around `unpack` for CLI layer.
214pub fn run_restore(in_path: PathBuf, target_dir: PathBuf) -> io::Result<()> {
215    unpack(&in_path, &target_dir).map(|_| ())
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    /// A fresh, empty, uniquely-named directory.
223    fn tmp(name: &str) -> PathBuf {
224        kevy_tmpdir::unique_dir(&format!("cli-backup-{name}"))
225    }
226
227    /// A unique path for a FILE that must not exist yet — pack's output. The
228    /// old helper returned an uncreated path and let the call site decide what
229    /// it was; that ambiguity is how the same function ended up naming both
230    /// directories and files.
231    fn tmp_file(name: &str) -> PathBuf {
232        tmp("files").join(name)
233    }
234
235    /// `pack` skips subdirectories. That is safe only while everything
236    /// under the data dir's subdirectories is derived spill the restore
237    /// can rebuild — true today for `tier/` (demoted values, reachable
238    /// through a snapshot that materialises them) and `segs-*/` (cold
239    /// index segments, rebuilt by re-sliding).
240    ///
241    /// This test does not verify that property; it pins the *set* so
242    /// that adding a subdirectory forces the person adding it to come
243    /// here and state which kind it is. A backup that silently omits a
244    /// directory of truth is the failure this is standing in front of.
245    #[test]
246    fn subdirectories_are_derived_spill_only() {
247        const DERIVED: [&str; 2] = ["tier", "segs"];
248        let src = tmp("derived");
249        std::fs::write(src.join("aof-0.aof"), b"truth").unwrap();
250        for d in ["tier/0", "segs-0"] {
251            std::fs::create_dir_all(src.join(d)).unwrap();
252            std::fs::write(src.join(d).join("spill.dat"), b"derived").unwrap();
253        }
254        let out = tmp_file("derived.kevybkp");
255        pack(&src, &out).unwrap();
256        let target = tmp("derived-restored");
257        unpack(&out, &target).unwrap();
258
259        assert_eq!(std::fs::read(target.join("aof-0.aof")).unwrap(), b"truth");
260        for d in ["tier", "segs-0"] {
261            assert!(!target.join(d).exists(), "{d} came along; it is spill, not truth");
262        }
263        // The guard: every subdirectory the source had must be one the
264        // list above already calls derived.
265        for e in std::fs::read_dir(&src).unwrap() {
266            let e = e.unwrap();
267            if e.metadata().unwrap().is_dir() {
268                let name = e.file_name().to_string_lossy().into_owned();
269                assert!(
270                    DERIVED.iter().any(|d| name.starts_with(d)),
271                    "'{name}' is a data-dir subdirectory the backup skips. \
272                     If it holds truth rather than derived spill, pack() \
273                     is losing it — see the comment at the skip."
274                );
275            }
276        }
277        std::fs::remove_dir_all(&src).ok();
278        std::fs::remove_dir_all(&target).ok();
279    }
280
281    #[test]
282    fn pack_unpack_round_trip() {
283        let src = tmp("src");
284        std::fs::write(src.join("aof-0.aof"), b"AOF body 1").unwrap();
285        std::fs::write(src.join("snap-0.rdb"), b"snapshot body").unwrap();
286        let out = tmp_file("backup.kevybkp");
287        pack(&src, &out).unwrap();
288
289        let target = tmp("restored");
290        unpack(&out, &target).unwrap();
291        assert_eq!(std::fs::read(target.join("aof-0.aof")).unwrap(), b"AOF body 1");
292        assert_eq!(std::fs::read(target.join("snap-0.rdb")).unwrap(), b"snapshot body");
293
294        std::fs::remove_dir_all(&src).ok();
295        std::fs::remove_file(&out).ok();
296        std::fs::remove_dir_all(&target).ok();
297    }
298
299    #[test]
300    fn unpack_refuses_path_traversal() {
301        let src = tmp("src2");
302        std::fs::create_dir_all(&src).unwrap();
303        let out_path = tmp_file("bad.kevybkp");
304        // Hand-craft a bad container with "../etc/passwd" name.
305        let mut f = File::create(&out_path).unwrap();
306        f.write_all(MAGIC).unwrap();
307        let name = b"../etc/passwd";
308        f.write_all(&(name.len() as u16).to_be_bytes()).unwrap();
309        f.write_all(name).unwrap();
310        f.write_all(&0u64.to_be_bytes()).unwrap();
311        f.write_all(&0u16.to_be_bytes()).unwrap();
312        drop(f);
313
314        let target = tmp("restored2");
315        let err = unpack(&out_path, &target).unwrap_err();
316        assert!(err.to_string().contains("path traversal"));
317
318        std::fs::remove_file(&out_path).ok();
319        std::fs::remove_dir_all(&target).ok();
320        std::fs::remove_dir_all(&src).ok();
321    }
322
323    #[test]
324    fn unpack_refuses_non_empty_target() {
325        let target = tmp("non-empty");
326        std::fs::create_dir_all(&target).unwrap();
327        std::fs::write(target.join("existing.txt"), b"data").unwrap();
328        let out_path = tmp_file("good.kevybkp");
329        let mut f = File::create(&out_path).unwrap();
330        f.write_all(MAGIC).unwrap();
331        f.write_all(&0u16.to_be_bytes()).unwrap();
332        drop(f);
333        let err = unpack(&out_path, &target).unwrap_err();
334        assert!(err.to_string().contains("not empty"));
335        std::fs::remove_dir_all(&target).ok();
336        std::fs::remove_file(&out_path).ok();
337    }
338}