Skip to main content

hayson_engine/database/
pack.rs

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