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`.
31pub fn pack(data_dir: &Path, out_path: &Path) -> io::Result<u64> {
32    let out = OpenOptions::new()
33        .create_new(true)
34        .write(true)
35        .open(out_path)?;
36    let mut w = BufWriter::new(out);
37    w.write_all(MAGIC)?;
38    let mut total_bytes: u64 = 0;
39    let mut file_count: u64 = 0;
40    for entry in std::fs::read_dir(data_dir)? {
41        let entry = entry?;
42        let path = entry.path();
43        let meta = entry.metadata()?;
44        if !meta.is_file() {
45            continue; // skip subdirs (kevy data_dir is flat anyway)
46        }
47        let name = path
48            .strip_prefix(data_dir)
49            .map_err(|_| io::Error::other("name not under data_dir"))?
50            .to_string_lossy()
51            .into_owned();
52        let name_bytes = name.as_bytes();
53        if name_bytes.len() > u16::MAX as usize {
54            return Err(io::Error::other("file name too long"));
55        }
56        let body_len = meta.len();
57        w.write_all(&(name_bytes.len() as u16).to_be_bytes())?;
58        w.write_all(name_bytes)?;
59        w.write_all(&body_len.to_be_bytes())?;
60        copy_file_body(&mut w, &path, body_len)?;
61        total_bytes += body_len;
62        file_count += 1;
63    }
64    // EOF marker: name_len = 0.
65    w.write_all(&0u16.to_be_bytes())?;
66    w.flush()?;
67    eprintln!(
68        "kevy-cli: backed up {file_count} file(s), {total_bytes} bytes total → {}",
69        out_path.display()
70    );
71    Ok(total_bytes)
72}
73
74/// Stream exactly `body_len` bytes of `path` into `w`.
75fn copy_file_body(w: &mut impl Write, path: &Path, body_len: u64) -> io::Result<()> {
76    let mut f = BufReader::new(File::open(path)?);
77    let mut buf = vec![0u8; 64 * 1024];
78    let mut copied = 0u64;
79    while copied < body_len {
80        let want = std::cmp::min((body_len - copied) as usize, buf.len());
81        let n = f.read(&mut buf[..want])?;
82        if n == 0 {
83            // File shrunk between metadata-stat and content-read
84            // (live backup race; AOF rewrite can shrink the file).
85            // Pad with zeros to honor the body_len we committed.
86            // Restore replay handles trailing zeros as torn-frame
87            // tail truncation (existing kevy-persist::replay logic).
88            let mut remaining = body_len - copied;
89            let zeros = [0u8; 64 * 1024];
90            while remaining > 0 {
91                let chunk = std::cmp::min(remaining as usize, zeros.len());
92                w.write_all(&zeros[..chunk])?;
93                remaining -= chunk as u64;
94            }
95            break;
96        }
97        w.write_all(&buf[..n])?;
98        copied += n as u64;
99    }
100    Ok(())
101}
102
103/// Unpack the container at `in_path` into `target_dir` (created if
104/// missing; refuses to overwrite an existing non-empty dir to avoid
105/// clobbering live data).
106pub fn unpack(in_path: &Path, target_dir: &Path) -> io::Result<u64> {
107    std::fs::create_dir_all(target_dir)?;
108    // Refuse to write into a non-empty dir (safety).
109    let existing = std::fs::read_dir(target_dir)?.count();
110    if existing > 0 {
111        return Err(io::Error::new(
112            io::ErrorKind::AlreadyExists,
113            format!(
114                "target dir {} is not empty ({existing} entries); refuse to overwrite",
115                target_dir.display()
116            ),
117        ));
118    }
119    let mut r = BufReader::new(File::open(in_path)?);
120    let mut magic = [0u8; 8];
121    r.read_exact(&mut magic)?;
122    if &magic != MAGIC {
123        return Err(io::Error::new(
124            io::ErrorKind::InvalidData,
125            "not a kevy backup container (magic mismatch)",
126        ));
127    }
128    let mut file_count = 0u64;
129    let mut total = 0u64;
130    // Loop ends at the EOF marker (read_entry_name -> None).
131    while let Some(name) = read_entry_name(&mut r)? {
132        let mut body_len_buf = [0u8; 8];
133        r.read_exact(&mut body_len_buf)?;
134        let body_len = u64::from_be_bytes(body_len_buf);
135        let out_path = target_dir.join(&name);
136        copy_entry_body(&mut r, &out_path, &name, body_len)?;
137        file_count += 1;
138        total += body_len;
139    }
140    eprintln!(
141        "kevy-cli: restored {file_count} file(s), {total} bytes total → {}",
142        target_dir.display()
143    );
144    Ok(total)
145}
146
147/// Read one entry's name header. `None` = the EOF marker (name_len 0).
148fn read_entry_name(r: &mut impl Read) -> io::Result<Option<String>> {
149    let mut name_len_buf = [0u8; 2];
150    r.read_exact(&mut name_len_buf)?;
151    let name_len = u16::from_be_bytes(name_len_buf);
152    if name_len == 0 {
153        return Ok(None);
154    }
155    let mut name_bytes = vec![0u8; name_len as usize];
156    r.read_exact(&mut name_bytes)?;
157    let name = std::str::from_utf8(&name_bytes)
158        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
159    // Reject path components that try to escape (e.g., "../etc/passwd").
160    if name.contains("..") || name.starts_with('/') {
161        return Err(io::Error::new(
162            io::ErrorKind::InvalidData,
163            format!("backup entry name {name:?} contains path traversal"),
164        ));
165    }
166    Ok(Some(name.to_owned()))
167}
168
169/// Stream exactly `body_len` bytes from `r` into a fresh file at
170/// `out_path`. `name` only feeds the truncation error message.
171fn copy_entry_body(
172    r: &mut impl Read,
173    out_path: &Path,
174    name: &str,
175    body_len: u64,
176) -> io::Result<()> {
177    let mut out = BufWriter::new(File::create(out_path)?);
178    let mut remaining = body_len;
179    let mut buf = vec![0u8; 64 * 1024];
180    while remaining > 0 {
181        let want = std::cmp::min(remaining as usize, buf.len());
182        let n = r.read(&mut buf[..want])?;
183        if n == 0 {
184            return Err(io::Error::new(
185                io::ErrorKind::UnexpectedEof,
186                format!("backup truncated mid-file {name:?}"),
187            ));
188        }
189        out.write_all(&buf[..n])?;
190        remaining -= n as u64;
191    }
192    out.flush()
193}
194
195/// Wrapper around `pack` that accepts string paths for the CLI layer.
196pub fn run_backup(data_dir: PathBuf, out_path: PathBuf) -> io::Result<()> {
197    pack(&data_dir, &out_path).map(|_| ())
198}
199
200/// Wrapper around `unpack` for CLI layer.
201pub fn run_restore(in_path: PathBuf, target_dir: PathBuf) -> io::Result<()> {
202    unpack(&in_path, &target_dir).map(|_| ())
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    /// A fresh, empty, uniquely-named directory.
210    fn tmp(name: &str) -> PathBuf {
211        kevy_tmpdir::unique_dir(&format!("cli-backup-{name}"))
212    }
213
214    /// A unique path for a FILE that must not exist yet — pack's output. The
215    /// old helper returned an uncreated path and let the call site decide what
216    /// it was; that ambiguity is how the same function ended up naming both
217    /// directories and files.
218    fn tmp_file(name: &str) -> PathBuf {
219        tmp("files").join(name)
220    }
221
222    #[test]
223    fn pack_unpack_round_trip() {
224        let src = tmp("src");
225        std::fs::write(src.join("aof-0.aof"), b"AOF body 1").unwrap();
226        std::fs::write(src.join("snap-0.rdb"), b"snapshot body").unwrap();
227        let out = tmp_file("backup.kevybkp");
228        pack(&src, &out).unwrap();
229
230        let target = tmp("restored");
231        unpack(&out, &target).unwrap();
232        assert_eq!(std::fs::read(target.join("aof-0.aof")).unwrap(), b"AOF body 1");
233        assert_eq!(std::fs::read(target.join("snap-0.rdb")).unwrap(), b"snapshot body");
234
235        std::fs::remove_dir_all(&src).ok();
236        std::fs::remove_file(&out).ok();
237        std::fs::remove_dir_all(&target).ok();
238    }
239
240    #[test]
241    fn unpack_refuses_path_traversal() {
242        let src = tmp("src2");
243        std::fs::create_dir_all(&src).unwrap();
244        let out_path = tmp_file("bad.kevybkp");
245        // Hand-craft a bad container with "../etc/passwd" name.
246        let mut f = File::create(&out_path).unwrap();
247        f.write_all(MAGIC).unwrap();
248        let name = b"../etc/passwd";
249        f.write_all(&(name.len() as u16).to_be_bytes()).unwrap();
250        f.write_all(name).unwrap();
251        f.write_all(&0u64.to_be_bytes()).unwrap();
252        f.write_all(&0u16.to_be_bytes()).unwrap();
253        drop(f);
254
255        let target = tmp("restored2");
256        let err = unpack(&out_path, &target).unwrap_err();
257        assert!(err.to_string().contains("path traversal"));
258
259        std::fs::remove_file(&out_path).ok();
260        std::fs::remove_dir_all(&target).ok();
261        std::fs::remove_dir_all(&src).ok();
262    }
263
264    #[test]
265    fn unpack_refuses_non_empty_target() {
266        let target = tmp("non-empty");
267        std::fs::create_dir_all(&target).unwrap();
268        std::fs::write(target.join("existing.txt"), b"data").unwrap();
269        let out_path = tmp_file("good.kevybkp");
270        let mut f = File::create(&out_path).unwrap();
271        f.write_all(MAGIC).unwrap();
272        f.write_all(&0u16.to_be_bytes()).unwrap();
273        drop(f);
274        let err = unpack(&out_path, &target).unwrap_err();
275        assert!(err.to_string().contains("not empty"));
276        std::fs::remove_dir_all(&target).ok();
277        std::fs::remove_file(&out_path).ok();
278    }
279}