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 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 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 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
97pub fn unpack(in_path: &Path, target_dir: &Path) -> io::Result<u64> {
101 std::fs::create_dir_all(target_dir)?;
102 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; }
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 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
172pub fn run_backup(data_dir: PathBuf, out_path: PathBuf) -> io::Result<()> {
174 pack(&data_dir, &out_path).map(|_| ())
175}
176
177pub 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 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}