Skip to main content

kevy_cli/
backup.rs

1//! v1.40 — `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        let mut f = BufReader::new(File::open(&path)?);
61        let mut buf = vec![0u8; 64 * 1024];
62        let mut copied = 0u64;
63        while copied < body_len {
64            let want = std::cmp::min((body_len - copied) as usize, buf.len());
65            let n = f.read(&mut buf[..want])?;
66            if n == 0 {
67                // File shrunk between metadata-stat and content-read
68                // (live backup race; AOF rewrite can shrink the file).
69                // Pad with zeros to honor the body_len we committed.
70                // Restore replay handles trailing zeros as torn-frame
71                // tail truncation (existing kevy-persist::replay logic).
72                let mut remaining = body_len - copied;
73                let zeros = [0u8; 64 * 1024];
74                while remaining > 0 {
75                    let chunk = std::cmp::min(remaining as usize, zeros.len());
76                    w.write_all(&zeros[..chunk])?;
77                    remaining -= chunk as u64;
78                }
79                break;
80            }
81            w.write_all(&buf[..n])?;
82            copied += n as u64;
83        }
84        total_bytes += body_len;
85        file_count += 1;
86    }
87    // EOF marker: name_len = 0.
88    w.write_all(&0u16.to_be_bytes())?;
89    w.flush()?;
90    eprintln!(
91        "kevy-cli: backed up {file_count} file(s), {total_bytes} bytes total → {}",
92        out_path.display()
93    );
94    Ok(total_bytes)
95}
96
97/// Unpack the container at `in_path` into `target_dir` (created if
98/// missing; refuses to overwrite an existing non-empty dir to avoid
99/// clobbering live data).
100pub fn unpack(in_path: &Path, target_dir: &Path) -> io::Result<u64> {
101    std::fs::create_dir_all(target_dir)?;
102    // Refuse to write into a non-empty dir (safety).
103    let existing = std::fs::read_dir(target_dir)?.count();
104    if existing > 0 {
105        return Err(io::Error::new(
106            io::ErrorKind::AlreadyExists,
107            format!(
108                "target dir {} is not empty ({existing} entries); refuse to overwrite",
109                target_dir.display()
110            ),
111        ));
112    }
113    let mut r = BufReader::new(File::open(in_path)?);
114    let mut magic = [0u8; 8];
115    r.read_exact(&mut magic)?;
116    if &magic != MAGIC {
117        return Err(io::Error::new(
118            io::ErrorKind::InvalidData,
119            "not a kevy backup container (magic mismatch)",
120        ));
121    }
122    let mut file_count = 0u64;
123    let mut total = 0u64;
124    loop {
125        let mut name_len_buf = [0u8; 2];
126        r.read_exact(&mut name_len_buf)?;
127        let name_len = u16::from_be_bytes(name_len_buf);
128        if name_len == 0 {
129            break; // EOF marker
130        }
131        let mut name_bytes = vec![0u8; name_len as usize];
132        r.read_exact(&mut name_bytes)?;
133        let name = std::str::from_utf8(&name_bytes)
134            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
135        // Reject path components that try to escape (e.g., "../etc/passwd").
136        if name.contains("..") || name.starts_with('/') {
137            return Err(io::Error::new(
138                io::ErrorKind::InvalidData,
139                format!("backup entry name {name:?} contains path traversal"),
140            ));
141        }
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        let mut out = BufWriter::new(File::create(&out_path)?);
147        let mut remaining = body_len;
148        let mut buf = vec![0u8; 64 * 1024];
149        while remaining > 0 {
150            let want = std::cmp::min(remaining as usize, buf.len());
151            let n = r.read(&mut buf[..want])?;
152            if n == 0 {
153                return Err(io::Error::new(
154                    io::ErrorKind::UnexpectedEof,
155                    format!("backup truncated mid-file {name:?}"),
156                ));
157            }
158            out.write_all(&buf[..n])?;
159            remaining -= n as u64;
160        }
161        out.flush()?;
162        file_count += 1;
163        total += body_len;
164    }
165    eprintln!(
166        "kevy-cli: restored {file_count} file(s), {total} bytes total → {}",
167        target_dir.display()
168    );
169    Ok(total)
170}
171
172/// Wrapper around `pack` that accepts string paths for the CLI layer.
173pub fn run_backup(data_dir: PathBuf, out_path: PathBuf) -> io::Result<()> {
174    pack(&data_dir, &out_path).map(|_| ())
175}
176
177/// Wrapper around `unpack` for CLI layer.
178pub fn run_restore(in_path: PathBuf, target_dir: PathBuf) -> io::Result<()> {
179    unpack(&in_path, &target_dir).map(|_| ())
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn tmp(name: &str) -> PathBuf {
187        let nanos = std::time::SystemTime::now()
188            .duration_since(std::time::UNIX_EPOCH)
189            .unwrap()
190            .as_nanos();
191        std::env::temp_dir().join(format!("kevy-cli-test-{name}-{nanos}"))
192    }
193
194    #[test]
195    fn pack_unpack_round_trip() {
196        let src = tmp("src");
197        std::fs::create_dir_all(&src).unwrap();
198        std::fs::write(src.join("aof-0.aof"), b"AOF body 1").unwrap();
199        std::fs::write(src.join("snap-0.rdb"), b"snapshot body").unwrap();
200        let out = tmp("backup.kevybkp");
201        pack(&src, &out).unwrap();
202
203        let target = tmp("restored");
204        unpack(&out, &target).unwrap();
205        assert_eq!(std::fs::read(target.join("aof-0.aof")).unwrap(), b"AOF body 1");
206        assert_eq!(std::fs::read(target.join("snap-0.rdb")).unwrap(), b"snapshot body");
207
208        std::fs::remove_dir_all(&src).ok();
209        std::fs::remove_file(&out).ok();
210        std::fs::remove_dir_all(&target).ok();
211    }
212
213    #[test]
214    fn unpack_refuses_path_traversal() {
215        let src = tmp("src2");
216        std::fs::create_dir_all(&src).unwrap();
217        let out_path = tmp("bad.kevybkp");
218        // Hand-craft a bad container with "../etc/passwd" name.
219        let mut f = File::create(&out_path).unwrap();
220        f.write_all(MAGIC).unwrap();
221        let name = b"../etc/passwd";
222        f.write_all(&(name.len() as u16).to_be_bytes()).unwrap();
223        f.write_all(name).unwrap();
224        f.write_all(&0u64.to_be_bytes()).unwrap();
225        f.write_all(&0u16.to_be_bytes()).unwrap();
226        drop(f);
227
228        let target = tmp("restored2");
229        let err = unpack(&out_path, &target).unwrap_err();
230        assert!(err.to_string().contains("path traversal"));
231
232        std::fs::remove_file(&out_path).ok();
233        std::fs::remove_dir_all(&target).ok();
234        std::fs::remove_dir_all(&src).ok();
235    }
236
237    #[test]
238    fn unpack_refuses_non_empty_target() {
239        let target = tmp("non-empty");
240        std::fs::create_dir_all(&target).unwrap();
241        std::fs::write(target.join("existing.txt"), b"data").unwrap();
242        let out_path = tmp("good.kevybkp");
243        let mut f = File::create(&out_path).unwrap();
244        f.write_all(MAGIC).unwrap();
245        f.write_all(&0u16.to_be_bytes()).unwrap();
246        drop(f);
247        let err = unpack(&out_path, &target).unwrap_err();
248        assert!(err.to_string().contains("not empty"));
249        std::fs::remove_dir_all(&target).ok();
250        std::fs::remove_file(&out_path).ok();
251    }
252}