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