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()
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; }
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 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
87fn 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 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
116pub fn unpack(in_path: &Path, target_dir: &Path) -> io::Result<u64> {
120 std::fs::create_dir_all(target_dir)?;
121 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 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
160fn 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 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
182fn 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
208pub fn run_backup(data_dir: PathBuf, out_path: PathBuf) -> io::Result<()> {
210 pack(&data_dir, &out_path).map(|_| ())
211}
212
213pub 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 fn tmp(name: &str) -> PathBuf {
224 kevy_tmpdir::unique_dir(&format!("cli-backup-{name}"))
225 }
226
227 fn tmp_file(name: &str) -> PathBuf {
232 tmp("files").join(name)
233 }
234
235 #[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 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 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}