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> {
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; }
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 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
74fn 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 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
103pub fn unpack(in_path: &Path, target_dir: &Path) -> io::Result<u64> {
107 std::fs::create_dir_all(target_dir)?;
108 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 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
147fn 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 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
169fn 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
195pub fn run_backup(data_dir: PathBuf, out_path: PathBuf) -> io::Result<()> {
197 pack(&data_dir, &out_path).map(|_| ())
198}
199
200pub 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 fn tmp(name: &str) -> PathBuf {
211 kevy_tmpdir::unique_dir(&format!("cli-backup-{name}"))
212 }
213
214 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 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}