1use std::collections::{BTreeSet, HashMap, HashSet};
2use std::fs;
3use std::io::{self, Read, Seek, SeekFrom, Write};
4use std::path::{Path, PathBuf};
5
6use serde_json::json;
7use zip::write::SimpleFileOptions;
8use zip::{CompressionMethod, ZipArchive, ZipWriter};
9
10use super::backup::{
11 check_relative, emptied_logs, hold, owned_files, parse_manifest, read_catalog_of, refuse, restore_staged, sibling,
12 ManifestEntry, RestoreResult, BACKUP_MANIFEST,
13};
14use super::database::{Database, DatabaseError, LockMode};
15use super::layout::LayoutOptions;
16
17const MIB: u64 = 1024 * 1024;
18const MAX_NAME_BYTES: usize = 1024;
19const MAX_COMPONENT_BYTES: usize = 255;
20const DEVICE_NAMES: [&str; 22] = [
21 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2",
22 "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
23];
24
25#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct PackLimits {
29 pub max_archive_bytes: u64,
31 pub max_total_bytes: u64,
33 pub max_entry_bytes: u64,
35 pub max_entries: usize,
36}
37
38impl Default for PackLimits {
39 fn default() -> Self {
40 PackLimits {
41 max_archive_bytes: 256 * MIB,
42 max_total_bytes: 256 * MIB,
43 max_entry_bytes: 128 * MIB,
44 max_entries: 10_000,
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
50pub struct PackOptions {
51 pub lock: LockMode,
53 pub indexes: bool,
55 pub exclude_tables: Vec<String>,
58 pub extra: Vec<(String, Vec<u8>)>,
61 pub limits: PackLimits,
62}
63
64impl Default for PackOptions {
65 fn default() -> Self {
66 PackOptions {
67 lock: LockMode::default(),
68 indexes: false,
69 exclude_tables: Vec::new(),
70 extra: Vec::new(),
71 limits: PackLimits::default(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct PackResult {
78 pub dest: PathBuf,
79 pub files: usize,
81 pub bytes: u64,
83 pub archive_bytes: u64,
85}
86
87#[derive(Debug, Clone, Default)]
88pub struct UnpackOptions {
89 pub lock: LockMode,
90 pub max_image_bytes: Option<u64>,
93 pub limits: PackLimits,
94}
95
96fn zip_error(error: zip::result::ZipError) -> DatabaseError {
97 match error {
98 zip::result::ZipError::Io(error) => DatabaseError::Io(error),
99 other => refuse(format!("not a readable archive: {other}")),
100 }
101}
102
103fn check_entry_name(name: &str) -> Result<(), DatabaseError> {
106 let bad = |why: &str| refuse(format!("archive entry name {name:?} is refused: {why}"));
107 if name.is_empty() || name.len() > MAX_NAME_BYTES {
108 return Err(bad("empty or too long"));
109 }
110 if name.starts_with('/') || name.contains('\\') {
111 return Err(bad("absolute path or backslash"));
112 }
113 if name.chars().any(|c| c.is_control()) {
114 return Err(bad("control character"));
115 }
116 for component in name.split('/') {
117 if component.is_empty() || component == "." || component == ".." {
118 return Err(bad("empty, \".\" or \"..\" component"));
119 }
120 if component.len() > MAX_COMPONENT_BYTES {
121 return Err(bad("component too long"));
122 }
123 if component.ends_with('.') || component.ends_with(' ') {
124 return Err(bad("component ends with a dot or a space"));
125 }
126 if component.contains(':') {
127 return Err(bad("drive letter or stream marker"));
128 }
129 let stem = component.split('.').next().unwrap_or("").trim_end().to_lowercase();
130 if DEVICE_NAMES.contains(&stem.as_str()) {
131 return Err(bad("device name"));
132 }
133 }
134 check_relative(Path::new(name))
135}
136
137fn fold(name: &str) -> String {
138 name.to_lowercase()
139}
140
141struct Inspected {
144 archive: ZipArchive<fs::File>,
145 files: HashMap<String, usize>,
147 sizes: HashMap<String, u64>,
148}
149
150fn declared_entries(path: &Path) -> Result<usize, DatabaseError> {
154 let mut file = fs::File::open(path)?;
155 let length = file.metadata()?.len();
156 let window = length.min(22 + u16::MAX as u64);
157 file.seek(SeekFrom::Start(length - window))?;
158 let mut tail = vec![0u8; window as usize];
159 file.read_exact(&mut tail)?;
160 for at in (0..tail.len().saturating_sub(21)).rev() {
161 if &tail[at..at + 4] != b"PK\x05\x06" {
162 continue;
163 }
164 let comment = u16::from_le_bytes([tail[at + 20], tail[at + 21]]) as usize;
165 if at + 22 + comment == tail.len() {
166 return Ok(u16::from_le_bytes([tail[at + 10], tail[at + 11]]) as usize);
167 }
168 }
169 Err(refuse(
170 "the archive does not end with its end-of-central-directory record — stray bytes follow it, or it is damaged"
171 .to_string(),
172 ))
173}
174
175fn local_name(file: &mut fs::File, header_start: u64) -> Result<Vec<u8>, DatabaseError> {
177 let mut fixed = [0u8; 30];
178 file.seek(SeekFrom::Start(header_start))?;
179 file.read_exact(&mut fixed)
180 .map_err(|_| refuse("an archive entry has a truncated local header".to_string()))?;
181 if &fixed[..4] != b"PK\x03\x04" {
182 return Err(refuse(
183 "an archive entry has no local header where the directory says".to_string(),
184 ));
185 }
186 let length = u16::from_le_bytes([fixed[26], fixed[27]]) as usize;
187 let mut name = vec![0u8; length];
188 file.read_exact(&mut name)
189 .map_err(|_| refuse("an archive entry has a truncated local header".to_string()))?;
190 Ok(name)
191}
192
193fn inspect(path: &Path, limits: &PackLimits) -> Result<Inspected, DatabaseError> {
194 let meta = fs::symlink_metadata(path)
195 .map_err(|error| refuse(format!("\"{}\" cannot be read: {error}", path.display())))?;
196 if !meta.is_file() {
197 return Err(refuse(format!("\"{}\" is not a regular file", path.display())));
198 }
199 if meta.len() > limits.max_archive_bytes {
200 return Err(refuse(format!(
201 "\"{}\" is {} bytes, over the {} byte limit",
202 path.display(),
203 meta.len(),
204 limits.max_archive_bytes
205 )));
206 }
207 let declared = declared_entries(path)?;
208 let mut archive = ZipArchive::new(fs::File::open(path)?).map_err(zip_error)?;
209 if archive.len() != declared {
210 return Err(refuse(format!(
211 "the archive declares {declared} entries but {} can be told apart — two share a name",
212 archive.len()
213 )));
214 }
215 let mut headers = fs::File::open(path)?;
216 if archive.len() > limits.max_entries {
217 return Err(refuse(format!(
218 "the archive has {} entries, over the limit of {}",
219 archive.len(),
220 limits.max_entries
221 )));
222 }
223 let mut files = HashMap::new();
224 let mut sizes = HashMap::new();
225 let mut seen: HashSet<String> = HashSet::new();
226 let mut total = 0u64;
227 for index in 0..archive.len() {
228 let entry = archive.by_index_raw(index).map_err(zip_error)?;
229 let raw_name = entry.name().to_string();
230 if std::str::from_utf8(entry.name_raw()) != Ok(raw_name.as_str()) {
231 return Err(refuse(format!(
232 "archive entry {raw_name:?} has a name that is not UTF-8"
233 )));
234 }
235 if local_name(&mut headers, entry.header_start())? != entry.name_raw() {
236 return Err(refuse(format!(
237 "archive entry {raw_name:?} is named differently in its local header and in the directory"
238 )));
239 }
240 let is_dir = raw_name.ends_with('/');
241 check_entry_name(raw_name.trim_end_matches('/'))?;
242 if !seen.insert(fold(raw_name.trim_end_matches('/'))) {
243 return Err(refuse(format!(
244 "archive entry {raw_name:?} appears twice (names are compared ignoring case)"
245 )));
246 }
247 if entry.encrypted() {
248 return Err(refuse(format!("archive entry {raw_name:?} is encrypted")));
249 }
250 if let Some(mode) = entry.unix_mode() {
251 let kind = mode & 0o170000;
252 if kind == 0o120000 {
253 return Err(refuse(format!("archive entry {raw_name:?} is a symbolic link")));
254 }
255 if kind != 0 && kind != 0o100000 && kind != 0o040000 {
256 return Err(refuse(format!("archive entry {raw_name:?} is not a regular file")));
257 }
258 }
259 if is_dir {
260 continue;
261 }
262 if entry.compression() != CompressionMethod::Stored {
263 return Err(refuse(format!(
264 "archive entry {raw_name:?} is compressed — packs are stored, never compressed"
265 )));
266 }
267 if entry.size() != entry.compressed_size() {
268 return Err(refuse(format!(
269 "archive entry {raw_name:?} declares inconsistent sizes"
270 )));
271 }
272 if entry.size() > limits.max_entry_bytes {
273 return Err(refuse(format!(
274 "archive entry {raw_name:?} declares {} bytes, over the {} byte limit",
275 entry.size(),
276 limits.max_entry_bytes
277 )));
278 }
279 total = total.saturating_add(entry.size());
280 if total > limits.max_total_bytes {
281 return Err(refuse(format!(
282 "the archive declares more than {} bytes in total",
283 limits.max_total_bytes
284 )));
285 }
286 sizes.insert(raw_name.clone(), entry.size());
287 files.insert(raw_name, index);
288 }
289 Ok(Inspected { archive, files, sizes })
290}
291
292fn read_entry(inspected: &mut Inspected, name: &str) -> Result<Option<Vec<u8>>, DatabaseError> {
293 let Some(&index) = inspected.files.get(name) else {
294 return Ok(None);
295 };
296 let declared = inspected.sizes[name];
297 let mut entry = inspected.archive.by_index(index).map_err(zip_error)?;
298 let mut bytes = Vec::with_capacity(declared as usize);
299 (&mut entry).take(declared + 1).read_to_end(&mut bytes)?;
300 if bytes.len() as u64 != declared {
301 return Err(refuse(format!("archive entry {name:?} is not the size it declares")));
302 }
303 Ok(Some(bytes))
304}
305
306pub fn read_pack_entry(archive: &Path, name: &str, limits: &PackLimits) -> Result<Option<Vec<u8>>, DatabaseError> {
309 let mut inspected = inspect(archive, limits)?;
310 read_entry(&mut inspected, name)
311}
312
313pub fn pack_database(dir: &Path, dest: &Path, options: &PackOptions) -> Result<PackResult, DatabaseError> {
321 let layout = Database::resolve_layout(dir, &LayoutOptions::default())?;
322 let catalog = read_catalog_of(&layout)?;
323 if dest.exists() {
324 return Err(refuse(format!("\"{}\" already exists", dest.display())));
325 }
326 let mut taken: BTreeSet<String> = BTreeSet::new();
327 taken.insert(fold(BACKUP_MANIFEST));
328 for (name, bytes) in &options.extra {
329 check_entry_name(name)?;
330 if !taken.insert(fold(name)) {
331 return Err(refuse(format!("extra entry {name:?} appears twice or is reserved")));
332 }
333 if bytes.len() as u64 > options.limits.max_entry_bytes {
334 return Err(refuse(format!("extra entry {name:?} is over the entry size limit")));
335 }
336 }
337 let partial = sibling(dest, ".partial");
338 if partial.exists() {
339 fs::remove_file(&partial)?;
340 }
341
342 let held = hold(dir, &layout, &options.lock)?;
343 let outcome = (|| -> Result<PackResult, DatabaseError> {
344 if layout.journal_path().exists() {
345 return Err(refuse(format!(
346 "\"{}\" holds an unfinished unit of work — open the database for writing once so it is finished, then pack it",
347 layout.journal_path().display()
348 )));
349 }
350 let mut files = owned_files(dir, &layout, &catalog, options.indexes, &options.exclude_tables)?;
351 let emptied = emptied_logs(dir, &layout, &catalog, &options.exclude_tables);
352 files.extend(emptied.iter().cloned());
353 files.sort();
354 if files.len() + options.extra.len() + 1 > options.limits.max_entries {
355 return Err(refuse(
356 "the database has more files than the entry limit allows".to_string(),
357 ));
358 }
359 let stored = SimpleFileOptions::default()
360 .compression_method(CompressionMethod::Stored)
361 .unix_permissions(0o644)
362 .large_file(false);
363 let mut writer = ZipWriter::new(fs::File::create(&partial)?);
364 let mut listed = Vec::new();
365 let mut total = 0u64;
366 for relative in &files {
367 let name = relative.to_string_lossy().replace('\\', "/");
368 check_entry_name(&name)?;
369 if !taken.insert(fold(&name)) {
370 return Err(refuse(format!("database file {name:?} collides with an extra entry")));
371 }
372 writer.start_file(name.as_str(), stored).map_err(zip_error)?;
373 let bytes = if emptied.contains(relative) {
374 0
375 } else {
376 let mut source = fs::File::open(dir.join(relative))?;
377 io::copy(&mut (&mut source).take(options.limits.max_entry_bytes + 1), &mut writer)?
378 };
379 if bytes > options.limits.max_entry_bytes {
380 return Err(refuse(format!(
381 "\"{name}\" is over the {} byte entry limit",
382 options.limits.max_entry_bytes
383 )));
384 }
385 total += bytes;
386 if total > options.limits.max_total_bytes {
387 return Err(refuse(format!(
388 "the database is over the {} byte limit",
389 options.limits.max_total_bytes
390 )));
391 }
392 listed.push(json!({ "path": name, "bytes": bytes }));
393 }
394 let manifest = json!({
395 "format": 1,
396 "createdAt": chrono::Utc::now().timestamp_millis(),
397 "engine": env!("CARGO_PKG_VERSION"),
398 "metadataFile": layout.metadata_file(),
399 "files": listed,
400 });
401 let mut text = serde_json::to_string_pretty(&manifest).map_err(|e| DatabaseError::Catalog(e.to_string()))?;
402 text.push('\n');
403 writer.start_file(BACKUP_MANIFEST, stored).map_err(zip_error)?;
404 writer.write_all(text.as_bytes())?;
405 for (name, bytes) in &options.extra {
406 writer.start_file(name.as_str(), stored).map_err(zip_error)?;
407 writer.write_all(bytes)?;
408 }
409 let file = writer.finish().map_err(zip_error)?;
410 file.sync_all()?;
411 let archive_bytes = file.metadata()?.len();
412 if archive_bytes > options.limits.max_archive_bytes {
413 return Err(refuse(format!(
414 "the archive would be {archive_bytes} bytes, over the {} byte limit",
415 options.limits.max_archive_bytes
416 )));
417 }
418 Ok(PackResult {
419 dest: dest.to_path_buf(),
420 files: files.len(),
421 bytes: total,
422 archive_bytes,
423 })
424 })();
425 drop(held);
426 match outcome {
427 Ok(result) => {
428 fs::rename(&partial, dest)?;
429 Ok(result)
430 }
431 Err(error) => {
432 let _ = fs::remove_file(&partial);
433 Err(error)
434 }
435 }
436}
437
438pub fn unpack_database(archive: &Path, dir: &Path, options: &UnpackOptions) -> Result<RestoreResult, DatabaseError> {
447 let mut inspected = inspect(archive, &options.limits)?;
448 let manifest = read_entry(&mut inspected, BACKUP_MANIFEST)?.ok_or_else(|| {
449 refuse(format!(
450 "the archive has no \"{BACKUP_MANIFEST}\" — it is not a hayson pack"
451 ))
452 })?;
453 let text = String::from_utf8(manifest).map_err(|_| refuse(format!("\"{BACKUP_MANIFEST}\" is not UTF-8")))?;
454 let (metadata_file, entries) = parse_manifest(&text, BACKUP_MANIFEST)?;
455 let mut listed: HashSet<String> = HashSet::new();
456 for entry in &entries {
457 let name = entry.relative.to_string_lossy().replace('\\', "/");
458 check_entry_name(&name)?;
459 if !listed.insert(fold(&name)) {
460 return Err(refuse(format!("the manifest lists {name:?} twice")));
461 }
462 match inspected.sizes.get(&name) {
463 Some(size) if *size == entry.bytes => {}
464 Some(_) => {
465 return Err(refuse(format!(
466 "archive entry {name:?} does not match its manifest entry ({} bytes expected)",
467 entry.bytes
468 )))
469 }
470 None => return Err(refuse(format!("archive entry {name:?} is missing"))),
471 }
472 }
473 restore_staged(
474 dir,
475 &metadata_file,
476 &entries,
477 &options.lock,
478 options.max_image_bytes,
479 |staged| extract(&mut inspected, &entries, staged),
480 )
481}
482
483fn extract(inspected: &mut Inspected, entries: &[ManifestEntry], staged: &Path) -> Result<u64, DatabaseError> {
484 let mut total = 0u64;
485 for entry in entries {
486 let name = entry.relative.to_string_lossy().replace('\\', "/");
487 let index = inspected.files[&name];
488 let target = staged.join(&entry.relative);
489 if let Some(parent) = target.parent() {
490 fs::create_dir_all(parent)?;
491 }
492 let mut source = inspected.archive.by_index(index).map_err(zip_error)?;
493 let mut out = fs::OpenOptions::new().write(true).create_new(true).open(&target)?;
494 let written = io::copy(&mut (&mut source).take(entry.bytes + 1), &mut out)?;
495 if written != entry.bytes {
496 return Err(refuse(format!("archive entry {name:?} is not the size it declares")));
497 }
498 out.sync_all()?;
499 total += written;
500 }
501 Ok(total)
502}
503
504#[cfg(test)]
505mod tests {
506 use super::check_entry_name;
507
508 #[test]
509 fn refuses_names_that_escape_or_confuse() {
510 for bad in [
511 "",
512 "/etc/passwd",
513 "../x",
514 "a/../x",
515 "a//b",
516 "./a",
517 "a\\b",
518 "C:/x",
519 "a\u{0}b",
520 "a\nb",
521 "con",
522 "tables/NUL.txt",
523 "a. ",
524 "a.",
525 ] {
526 assert!(check_entry_name(bad).is_err(), "{bad:?} should be refused");
527 }
528 for good in [
529 "hayson.json",
530 "tables/users.jsonl",
531 "tables/users.images/ab/cd.png",
532 "gly.json",
533 ] {
534 assert!(check_entry_name(good).is_ok(), "{good:?} should be accepted");
535 }
536 }
537}