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, Value};
7use zip::write::SimpleFileOptions;
8use zip::{CompressionMethod, ZipArchive, ZipWriter};
9
10use crate::catalog::Catalog;
11
12use super::backup::{
13 check_relative, emptied_logs, hold, owned_files, parse_manifest, read_catalog_of, refuse, restore_staged, sibling,
14 ManifestEntry, RestorePart, RestoreResult, BACKUP_MANIFEST,
15};
16use super::database::{Database, DatabaseError, LockMode};
17use super::layout::{Layout, LayoutOptions};
18
19const MIB: u64 = 1024 * 1024;
20const MAX_NAME_BYTES: usize = 1024;
21const MAX_COMPONENT_BYTES: usize = 255;
22const DEVICE_NAMES: [&str; 22] = [
23 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2",
24 "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
25];
26
27#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PackLimits {
31 pub max_archive_bytes: u64,
33 pub max_total_bytes: u64,
35 pub max_entry_bytes: u64,
37 pub max_entries: usize,
38}
39
40impl Default for PackLimits {
41 fn default() -> Self {
42 PackLimits {
43 max_archive_bytes: 256 * MIB,
44 max_total_bytes: 256 * MIB,
45 max_entry_bytes: 128 * MIB,
46 max_entries: 10_000,
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
52pub struct PackOptions {
53 pub lock: LockMode,
55 pub indexes: bool,
57 pub exclude_tables: Vec<String>,
60 pub include: Vec<PackInclude>,
64 pub extra: Vec<(String, Vec<u8>)>,
67 pub limits: PackLimits,
68}
69
70impl Default for PackOptions {
71 fn default() -> Self {
72 PackOptions {
73 lock: LockMode::default(),
74 indexes: false,
75 exclude_tables: Vec::new(),
76 include: Vec::new(),
77 extra: Vec::new(),
78 limits: PackLimits::default(),
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Default)]
85pub struct PackInclude {
86 pub path: String,
88 pub exclude_tables: Vec<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct PackedDatabase {
95 pub path: String,
97 pub files: usize,
98 pub bytes: u64,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct PackResult {
103 pub dest: PathBuf,
104 pub files: usize,
106 pub bytes: u64,
108 pub archive_bytes: u64,
110 pub databases: Vec<PackedDatabase>,
112}
113
114#[derive(Debug, Clone, Default)]
115pub struct UnpackOptions {
116 pub lock: LockMode,
117 pub max_image_bytes: Option<u64>,
120 pub limits: PackLimits,
121}
122
123fn zip_error(error: zip::result::ZipError) -> DatabaseError {
124 match error {
125 zip::result::ZipError::Io(error) => DatabaseError::Io(error),
126 other => refuse(format!("not a readable archive: {other}")),
127 }
128}
129
130fn check_entry_name(name: &str) -> Result<(), DatabaseError> {
133 let bad = |why: &str| refuse(format!("archive entry name {name:?} is refused: {why}"));
134 if name.is_empty() || name.len() > MAX_NAME_BYTES {
135 return Err(bad("empty or too long"));
136 }
137 if name.starts_with('/') || name.contains('\\') {
138 return Err(bad("absolute path or backslash"));
139 }
140 if name.chars().any(|c| c.is_control()) {
141 return Err(bad("control character"));
142 }
143 for component in name.split('/') {
144 if component.is_empty() || component == "." || component == ".." {
145 return Err(bad("empty, \".\" or \"..\" component"));
146 }
147 if component.len() > MAX_COMPONENT_BYTES {
148 return Err(bad("component too long"));
149 }
150 if component.ends_with('.') || component.ends_with(' ') {
151 return Err(bad("component ends with a dot or a space"));
152 }
153 if component.contains(':') {
154 return Err(bad("drive letter or stream marker"));
155 }
156 let stem = component.split('.').next().unwrap_or("").trim_end().to_lowercase();
157 if DEVICE_NAMES.contains(&stem.as_str()) {
158 return Err(bad("device name"));
159 }
160 }
161 check_relative(Path::new(name))
162}
163
164fn fold(name: &str) -> String {
165 name.to_lowercase()
166}
167
168struct Inspected {
171 archive: ZipArchive<fs::File>,
172 files: HashMap<String, usize>,
174 sizes: HashMap<String, u64>,
175}
176
177fn declared_entries(path: &Path) -> Result<usize, DatabaseError> {
181 let mut file = fs::File::open(path)?;
182 let length = file.metadata()?.len();
183 let window = length.min(22 + u16::MAX as u64);
184 file.seek(SeekFrom::Start(length - window))?;
185 let mut tail = vec![0u8; window as usize];
186 file.read_exact(&mut tail)?;
187 for at in (0..tail.len().saturating_sub(21)).rev() {
188 if &tail[at..at + 4] != b"PK\x05\x06" {
189 continue;
190 }
191 let comment = u16::from_le_bytes([tail[at + 20], tail[at + 21]]) as usize;
192 if at + 22 + comment == tail.len() {
193 return Ok(u16::from_le_bytes([tail[at + 10], tail[at + 11]]) as usize);
194 }
195 }
196 Err(refuse(
197 "the archive does not end with its end-of-central-directory record — stray bytes follow it, or it is damaged"
198 .to_string(),
199 ))
200}
201
202fn local_name(file: &mut fs::File, header_start: u64) -> Result<Vec<u8>, DatabaseError> {
204 let mut fixed = [0u8; 30];
205 file.seek(SeekFrom::Start(header_start))?;
206 file.read_exact(&mut fixed)
207 .map_err(|_| refuse("an archive entry has a truncated local header".to_string()))?;
208 if &fixed[..4] != b"PK\x03\x04" {
209 return Err(refuse(
210 "an archive entry has no local header where the directory says".to_string(),
211 ));
212 }
213 let length = u16::from_le_bytes([fixed[26], fixed[27]]) as usize;
214 let mut name = vec![0u8; length];
215 file.read_exact(&mut name)
216 .map_err(|_| refuse("an archive entry has a truncated local header".to_string()))?;
217 Ok(name)
218}
219
220fn inspect(path: &Path, limits: &PackLimits) -> Result<Inspected, DatabaseError> {
221 let meta = fs::symlink_metadata(path)
222 .map_err(|error| refuse(format!("\"{}\" cannot be read: {error}", path.display())))?;
223 if !meta.is_file() {
224 return Err(refuse(format!("\"{}\" is not a regular file", path.display())));
225 }
226 if meta.len() > limits.max_archive_bytes {
227 return Err(refuse(format!(
228 "\"{}\" is {} bytes, over the {} byte limit",
229 path.display(),
230 meta.len(),
231 limits.max_archive_bytes
232 )));
233 }
234 let declared = declared_entries(path)?;
235 let mut archive = ZipArchive::new(fs::File::open(path)?).map_err(zip_error)?;
236 if archive.len() != declared {
237 return Err(refuse(format!(
238 "the archive declares {declared} entries but {} can be told apart — two share a name",
239 archive.len()
240 )));
241 }
242 let mut headers = fs::File::open(path)?;
243 if archive.len() > limits.max_entries {
244 return Err(refuse(format!(
245 "the archive has {} entries, over the limit of {}",
246 archive.len(),
247 limits.max_entries
248 )));
249 }
250 let mut files = HashMap::new();
251 let mut sizes = HashMap::new();
252 let mut seen: HashSet<String> = HashSet::new();
253 let mut total = 0u64;
254 for index in 0..archive.len() {
255 let entry = archive.by_index_raw(index).map_err(zip_error)?;
256 let raw_name = entry.name().to_string();
257 if std::str::from_utf8(entry.name_raw()) != Ok(raw_name.as_str()) {
258 return Err(refuse(format!(
259 "archive entry {raw_name:?} has a name that is not UTF-8"
260 )));
261 }
262 if local_name(&mut headers, entry.header_start())? != entry.name_raw() {
263 return Err(refuse(format!(
264 "archive entry {raw_name:?} is named differently in its local header and in the directory"
265 )));
266 }
267 let is_dir = raw_name.ends_with('/');
268 check_entry_name(raw_name.trim_end_matches('/'))?;
269 if !seen.insert(fold(raw_name.trim_end_matches('/'))) {
270 return Err(refuse(format!(
271 "archive entry {raw_name:?} appears twice (names are compared ignoring case)"
272 )));
273 }
274 if entry.encrypted() {
275 return Err(refuse(format!("archive entry {raw_name:?} is encrypted")));
276 }
277 if let Some(mode) = entry.unix_mode() {
278 let kind = mode & 0o170000;
279 if kind == 0o120000 {
280 return Err(refuse(format!("archive entry {raw_name:?} is a symbolic link")));
281 }
282 if kind != 0 && kind != 0o100000 && kind != 0o040000 {
283 return Err(refuse(format!("archive entry {raw_name:?} is not a regular file")));
284 }
285 }
286 if is_dir {
287 continue;
288 }
289 if entry.compression() != CompressionMethod::Stored {
290 return Err(refuse(format!(
291 "archive entry {raw_name:?} is compressed — packs are stored, never compressed"
292 )));
293 }
294 if entry.size() != entry.compressed_size() {
295 return Err(refuse(format!(
296 "archive entry {raw_name:?} declares inconsistent sizes"
297 )));
298 }
299 if entry.size() > limits.max_entry_bytes {
300 return Err(refuse(format!(
301 "archive entry {raw_name:?} declares {} bytes, over the {} byte limit",
302 entry.size(),
303 limits.max_entry_bytes
304 )));
305 }
306 total = total.saturating_add(entry.size());
307 if total > limits.max_total_bytes {
308 return Err(refuse(format!(
309 "the archive declares more than {} bytes in total",
310 limits.max_total_bytes
311 )));
312 }
313 sizes.insert(raw_name.clone(), entry.size());
314 files.insert(raw_name, index);
315 }
316 Ok(Inspected { archive, files, sizes })
317}
318
319fn read_entry(inspected: &mut Inspected, name: &str) -> Result<Option<Vec<u8>>, DatabaseError> {
320 let Some(&index) = inspected.files.get(name) else {
321 return Ok(None);
322 };
323 let declared = inspected.sizes[name];
324 let mut entry = inspected.archive.by_index(index).map_err(zip_error)?;
325 let mut bytes = Vec::with_capacity(declared as usize);
326 (&mut entry).take(declared + 1).read_to_end(&mut bytes)?;
327 if bytes.len() as u64 != declared {
328 return Err(refuse(format!("archive entry {name:?} is not the size it declares")));
329 }
330 Ok(Some(bytes))
331}
332
333pub fn read_pack_entry(archive: &Path, name: &str, limits: &PackLimits) -> Result<Option<Vec<u8>>, DatabaseError> {
336 let mut inspected = inspect(archive, limits)?;
337 read_entry(&mut inspected, name)
338}
339
340struct PackPart {
341 sub: String,
342 root: PathBuf,
343 layout: Layout,
344 catalog: Catalog,
345 exclude: Vec<String>,
346}
347
348fn pack_parts(dir: &Path, options: &PackOptions) -> Result<Vec<PackPart>, DatabaseError> {
349 let layout = Database::resolve_layout(dir, &LayoutOptions::default())?;
350 let catalog = read_catalog_of(&layout)?;
351 let mut parts = vec![PackPart {
352 sub: String::new(),
353 root: dir.to_path_buf(),
354 layout,
355 catalog,
356 exclude: options.exclude_tables.clone(),
357 }];
358 let mut seen: Vec<String> = Vec::new();
359 for include in &options.include {
360 let sub = include.path.trim_end_matches('/').to_string();
361 check_entry_name(&sub)?;
362 let folded = fold(&sub);
363 let overlaps = |other: &str| {
364 folded == other || folded.starts_with(&format!("{other}/")) || other.starts_with(&format!("{folded}/"))
365 };
366 if seen.iter().any(|other| overlaps(other)) {
367 return Err(refuse(format!(
368 "included database {sub:?} appears twice or sits inside another included one"
369 )));
370 }
371 seen.push(folded);
372 let root = dir.join(&sub);
373 let layout = Database::resolve_layout(&root, &LayoutOptions::default())?;
374 let catalog = read_catalog_of(&layout)?;
375 parts.push(PackPart {
376 sub,
377 root,
378 layout,
379 catalog,
380 exclude: include.exclude_tables.clone(),
381 });
382 }
383 Ok(parts)
384}
385
386fn prefixed(sub: &str, name: &str) -> String {
387 if sub.is_empty() {
388 name.to_string()
389 } else {
390 format!("{sub}/{name}")
391 }
392}
393
394pub fn pack_database(dir: &Path, dest: &Path, options: &PackOptions) -> Result<PackResult, DatabaseError> {
404 let parts = pack_parts(dir, options)?;
405 if dest.exists() {
406 return Err(refuse(format!("\"{}\" already exists", dest.display())));
407 }
408 let mut taken: BTreeSet<String> = BTreeSet::new();
409 for part in &parts {
410 taken.insert(fold(&prefixed(&part.sub, BACKUP_MANIFEST)));
411 }
412 for (name, bytes) in &options.extra {
413 check_entry_name(name)?;
414 if !taken.insert(fold(name)) {
415 return Err(refuse(format!("extra entry {name:?} appears twice or is reserved")));
416 }
417 if bytes.len() as u64 > options.limits.max_entry_bytes {
418 return Err(refuse(format!("extra entry {name:?} is over the entry size limit")));
419 }
420 }
421 let partial = sibling(dest, ".partial");
422 if partial.exists() {
423 fs::remove_file(&partial)?;
424 }
425
426 let mut held = Vec::new();
427 for part in &parts {
428 held.push(hold(&part.root, &part.layout, &options.lock)?);
429 }
430 let outcome = (|| -> Result<PackResult, DatabaseError> {
431 for part in &parts {
432 if part.layout.journal_path().exists() {
433 return Err(refuse(format!(
434 "\"{}\" holds an unfinished unit of work — open the database for writing once so it is finished, then pack it",
435 part.layout.journal_path().display()
436 )));
437 }
438 }
439 let mut planned = Vec::new();
440 let mut entry_count = options.extra.len() + parts.len();
441 for part in &parts {
442 let mut files = owned_files(&part.root, &part.layout, &part.catalog, options.indexes, &part.exclude)?;
443 let emptied = emptied_logs(&part.root, &part.layout, &part.catalog, &part.exclude);
444 files.extend(emptied.iter().cloned());
445 files.sort();
446 entry_count += files.len();
447 planned.push((files, emptied));
448 }
449 if entry_count > options.limits.max_entries {
450 return Err(refuse(
451 "the databases have more files than the entry limit allows".to_string(),
452 ));
453 }
454 let stored = SimpleFileOptions::default()
455 .compression_method(CompressionMethod::Stored)
456 .unix_permissions(0o644)
457 .large_file(false);
458 let mut writer = ZipWriter::new(fs::File::create(&partial)?);
459 let mut total = 0u64;
460 let mut file_count = 0usize;
461 let mut manifests: Vec<(String, Vec<Value>)> = Vec::new();
462 let mut summary = Vec::new();
463 for (part, (files, emptied)) in parts.iter().zip(&planned) {
464 let mut listed = Vec::new();
465 let mut part_bytes = 0u64;
466 for relative in files {
467 let own = relative.to_string_lossy().replace('\\', "/");
468 let name = prefixed(&part.sub, &own);
469 check_entry_name(&name)?;
470 if !taken.insert(fold(&name)) {
471 return Err(refuse(format!("database file {name:?} collides with another entry")));
472 }
473 writer.start_file(name.as_str(), stored).map_err(zip_error)?;
474 let bytes = if emptied.contains(relative) {
475 0
476 } else {
477 let mut source = fs::File::open(part.root.join(relative))?;
478 io::copy(&mut (&mut source).take(options.limits.max_entry_bytes + 1), &mut writer)?
479 };
480 if bytes > options.limits.max_entry_bytes {
481 return Err(refuse(format!(
482 "\"{name}\" is over the {} byte entry limit",
483 options.limits.max_entry_bytes
484 )));
485 }
486 total += bytes;
487 part_bytes += bytes;
488 if total > options.limits.max_total_bytes {
489 return Err(refuse(format!(
490 "the databases are over the {} byte limit",
491 options.limits.max_total_bytes
492 )));
493 }
494 listed.push(json!({ "path": own, "bytes": bytes }));
495 }
496 file_count += files.len();
497 summary.push(PackedDatabase {
498 path: part.sub.clone(),
499 files: files.len(),
500 bytes: part_bytes,
501 });
502 manifests.push((part.sub.clone(), listed));
503 }
504 let nested: Vec<&str> = parts.iter().skip(1).map(|part| part.sub.as_str()).collect();
505 for (part, (sub, listed)) in parts.iter().zip(manifests) {
506 let mut manifest = json!({
507 "format": 1,
508 "createdAt": chrono::Utc::now().timestamp_millis(),
509 "engine": env!("CARGO_PKG_VERSION"),
510 "metadataFile": part.layout.metadata_file(),
511 "files": listed,
512 });
513 if sub.is_empty() && !nested.is_empty() {
514 manifest["databases"] = json!(nested);
515 }
516 let mut text =
517 serde_json::to_string_pretty(&manifest).map_err(|e| DatabaseError::Catalog(e.to_string()))?;
518 text.push('\n');
519 writer
520 .start_file(prefixed(&sub, BACKUP_MANIFEST).as_str(), stored)
521 .map_err(zip_error)?;
522 writer.write_all(text.as_bytes())?;
523 }
524 for (name, bytes) in &options.extra {
525 writer.start_file(name.as_str(), stored).map_err(zip_error)?;
526 writer.write_all(bytes)?;
527 }
528 let file = writer.finish().map_err(zip_error)?;
529 file.sync_all()?;
530 let archive_bytes = file.metadata()?.len();
531 if archive_bytes > options.limits.max_archive_bytes {
532 return Err(refuse(format!(
533 "the archive would be {archive_bytes} bytes, over the {} byte limit",
534 options.limits.max_archive_bytes
535 )));
536 }
537 Ok(PackResult {
538 dest: dest.to_path_buf(),
539 files: file_count,
540 bytes: total,
541 archive_bytes,
542 databases: summary,
543 })
544 })();
545 drop(held);
546 match outcome {
547 Ok(result) => {
548 fs::rename(&partial, dest)?;
549 Ok(result)
550 }
551 Err(error) => {
552 let _ = fs::remove_file(&partial);
553 Err(error)
554 }
555 }
556}
557
558fn manifest_of(
559 inspected: &mut Inspected,
560 sub: &str,
561) -> Result<(String, Vec<ManifestEntry>, Option<Vec<String>>), DatabaseError> {
562 let name = prefixed(sub, BACKUP_MANIFEST);
563 let manifest = read_entry(inspected, &name)?
564 .ok_or_else(|| refuse(format!("the archive has no \"{name}\" — it is not a hayson pack")))?;
565 let text = String::from_utf8(manifest).map_err(|_| refuse(format!("\"{name}\" is not UTF-8")))?;
566 let (metadata_file, mut entries) = parse_manifest(&text, &name)?;
567 let nested = if sub.is_empty() {
568 let document: Value =
569 serde_json::from_str(&text).map_err(|e| refuse(format!("\"{name}\" is not JSON: {e}")))?;
570 match document.get("databases") {
571 None | Some(Value::Null) => None,
572 Some(Value::Array(items)) => Some(
573 items
574 .iter()
575 .map(|item| {
576 item.as_str()
577 .map(str::to_string)
578 .ok_or_else(|| refuse("\"databases\" must list folder names".to_string()))
579 })
580 .collect::<Result<Vec<_>, _>>()?,
581 ),
582 Some(_) => return Err(refuse("\"databases\" must list folder names".to_string())),
583 }
584 } else {
585 None
586 };
587 if !sub.is_empty() {
588 for entry in &mut entries {
589 entry.relative = Path::new(sub).join(&entry.relative);
590 }
591 }
592 Ok((metadata_file, entries, nested))
593}
594
595pub fn unpack_database(archive: &Path, dir: &Path, options: &UnpackOptions) -> Result<RestoreResult, DatabaseError> {
606 let mut inspected = inspect(archive, &options.limits)?;
607 let (metadata_file, entries, nested) = manifest_of(&mut inspected, "")?;
608 let mut parts = vec![RestorePart {
609 sub: PathBuf::new(),
610 metadata_file,
611 entries,
612 }];
613 let mut folders: Vec<String> = Vec::new();
614 for sub in nested.unwrap_or_default() {
615 check_entry_name(&sub)?;
616 let folded = fold(&sub);
617 let overlaps = |other: &str| {
618 folded == other || folded.starts_with(&format!("{other}/")) || other.starts_with(&format!("{folded}/"))
619 };
620 if folders.iter().any(|other| overlaps(other)) {
621 return Err(refuse(format!(
622 "the manifest lists database {sub:?} twice or inside another listed one"
623 )));
624 }
625 folders.push(folded);
626 let (metadata_file, entries, _) = manifest_of(&mut inspected, &sub)?;
627 parts.push(RestorePart {
628 sub: PathBuf::from(&sub),
629 metadata_file,
630 entries,
631 });
632 }
633 let mut listed: HashSet<String> = HashSet::new();
634 for part in &parts {
635 for entry in &part.entries {
636 let name = entry.relative.to_string_lossy().replace('\\', "/");
637 check_entry_name(&name)?;
638 if !listed.insert(fold(&name)) {
639 return Err(refuse(format!("the manifests list {name:?} twice")));
640 }
641 match inspected.sizes.get(&name) {
642 Some(size) if *size == entry.bytes => {}
643 Some(_) => {
644 return Err(refuse(format!(
645 "archive entry {name:?} does not match its manifest entry ({} bytes expected)",
646 entry.bytes
647 )))
648 }
649 None => return Err(refuse(format!("archive entry {name:?} is missing"))),
650 }
651 }
652 }
653 let all: Vec<ManifestEntry> = parts.iter().flat_map(|part| part.entries.iter().cloned()).collect();
654 restore_staged(dir, &parts, &options.lock, options.max_image_bytes, |staged| {
655 extract(&mut inspected, &all, staged)
656 })
657}
658
659fn extract(inspected: &mut Inspected, entries: &[ManifestEntry], staged: &Path) -> Result<u64, DatabaseError> {
660 let mut total = 0u64;
661 for entry in entries {
662 let name = entry.relative.to_string_lossy().replace('\\', "/");
663 let index = inspected.files[&name];
664 let target = staged.join(&entry.relative);
665 if let Some(parent) = target.parent() {
666 fs::create_dir_all(parent)?;
667 }
668 let mut source = inspected.archive.by_index(index).map_err(zip_error)?;
669 let mut out = fs::OpenOptions::new().write(true).create_new(true).open(&target)?;
670 let written = io::copy(&mut (&mut source).take(entry.bytes + 1), &mut out)?;
671 if written != entry.bytes {
672 return Err(refuse(format!("archive entry {name:?} is not the size it declares")));
673 }
674 out.sync_all()?;
675 total += written;
676 }
677 Ok(total)
678}
679
680#[cfg(test)]
681mod tests {
682 use super::check_entry_name;
683
684 #[test]
685 fn refuses_names_that_escape_or_confuse() {
686 for bad in [
687 "",
688 "/etc/passwd",
689 "../x",
690 "a/../x",
691 "a//b",
692 "./a",
693 "a\\b",
694 "C:/x",
695 "a\u{0}b",
696 "a\nb",
697 "con",
698 "tables/NUL.txt",
699 "a. ",
700 "a.",
701 ] {
702 assert!(check_entry_name(bad).is_err(), "{bad:?} should be refused");
703 }
704 for good in [
705 "hayson.json",
706 "tables/users.jsonl",
707 "tables/users.images/ab/cd.png",
708 "gly.json",
709 ] {
710 assert!(check_entry_name(good).is_ok(), "{good:?} should be accepted");
711 }
712 }
713}