1use 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
30pub 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; }
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 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
84fn 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 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
113pub fn unpack(in_path: &Path, target_dir: &Path) -> io::Result<u64> {
117 std::fs::create_dir_all(target_dir)?;
118 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 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
157fn 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 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
179fn 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
205pub fn run_backup(data_dir: PathBuf, out_path: PathBuf) -> io::Result<()> {
207 pack(&data_dir, &out_path).map(|_| ())
208}
209
210pub 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 fn tmp(name: &str) -> PathBuf {
221 kevy_tmpdir::unique_dir(&format!("cli-backup-{name}"))
222 }
223
224 fn tmp_file(name: &str) -> PathBuf {
229 tmp("files").join(name)
230 }
231
232 #[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 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 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}