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;
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/// What a pack may hold and what an unpack accepts. The same limits guard both directions, so a
28/// pack this engine writes is one it reads back.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PackLimits {
31    /// Size of the archive file itself.
32    pub max_archive_bytes: u64,
33    /// Sum of the declared sizes of every entry.
34    pub max_total_bytes: u64,
35    /// Declared size of a single entry.
36    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    /// How the writer lock is taken for the length of the read. As in [`super::BackupOptions`].
54    pub lock: LockMode,
55    /// Also pack the `<table>.idx` snapshots (caches; off by default).
56    pub indexes: bool,
57    /// Tables whose data is left out of the archive and its manifest — see
58    /// [`super::BackupOptions::exclude_tables`].
59    pub exclude_tables: Vec<String>,
60    /// Further databases living in subfolders of the directory being packed (a Universe with its
61    /// Projects, say), each with its own tables to leave out. They travel in the same archive under
62    /// their folder name, each with a manifest of its own, and are unpacked together.
63    pub include: Vec<PackInclude>,
64    /// Files of the caller's own to store next to the database files (a manifest of the application's
65    /// format, say), as `(name inside the archive, bytes)`. Read back with [`read_pack_entry`].
66    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/// A nested database to pack along with the main one.
84#[derive(Debug, Clone, PartialEq, Eq, Default)]
85pub struct PackInclude {
86    /// Its folder, relative to the packed directory and `/`-separated.
87    pub path: String,
88    /// Tables of that database whose data stays out, as in [`PackOptions::exclude_tables`].
89    pub exclude_tables: Vec<String>,
90}
91
92/// What one packed database contributed.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct PackedDatabase {
95    /// Its folder relative to the packed directory; empty for the directory itself.
96    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    /// Database files in the archive (not counting the manifest and the caller's extra entries).
105    pub files: usize,
106    /// Their size.
107    pub bytes: u64,
108    /// Size of the archive file.
109    pub archive_bytes: u64,
110    /// The same totals per database, the packed directory's own first.
111    pub databases: Vec<PackedDatabase>,
112}
113
114#[derive(Debug, Clone, Default)]
115pub struct UnpackOptions {
116    pub lock: LockMode,
117    /// Refuses a pack holding an image file larger than this, after extraction but before anything
118    /// in the target is replaced.
119    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
130/// An entry name this engine writes or extracts: relative, `/`-separated, no empty, `.` or `..`
131/// component, no control character, no Windows device name, no trailing dot or space.
132fn 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
168/// The result of reading an archive's central directory and judging every entry before any byte is
169/// extracted.
170struct Inspected {
171    archive: ZipArchive<fs::File>,
172    /// Non-directory entries: name → index.
173    files: HashMap<String, usize>,
174    sizes: HashMap<String, u64>,
175}
176
177/// How many entries the end-of-central-directory record declares, after making sure that record is
178/// the last thing in the file: the zip reader tolerates bytes after it, and collapses entries that
179/// share a name, so both are read off the record itself.
180fn 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
202/// The name the local header of the entry at `header_start` carries.
203fn 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
333/// Reads one entry of a pack — the caller's own manifest, say — after the same checks an unpack makes.
334/// `None` when the archive has no entry of that name.
335pub 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
394/// Packs a database directory into one zip archive at `dest`: the files the database accounts for,
395/// stored without compression, plus the `hayson-backup.json` manifest and the caller's `extra` entries.
396/// Databases named in `include` travel in the same archive under their folder, each with a
397/// `<folder>/hayson-backup.json` of its own; the main manifest lists their folders.
398///
399/// `dest` must not exist; the archive is built next to it and renamed into place. Every database is
400/// held under its writer lock while it is read, and one with an unfinished unit of work is refused,
401/// as in [`super::backup_database`]. Limits are enforced here too, so what is packed can be
402/// unpacked. History kept in a database of its own is not part of the database's files.
403pub 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
595/// Unpacks an archive made by [`pack_database`] into `dir`, replacing the database that is there
596/// (and the nested ones the archive carries), with the recoverable procedure of
597/// [`super::restore_database`] (stage, verify, swap, roll back).
598///
599/// The archive is judged before anything is written: size and entry-count limits, names (no `..`,
600/// absolute or drive paths, backslashes, control characters, device names, duplicates ignoring case),
601/// no symbolic links, no compressed or encrypted entries, declared sizes against the limits and against
602/// what each entry really holds. Entries the manifests do not list — the caller's extras — are not
603/// extracted. Every database is staged and verified before any is swapped in, and one failure puts all
604/// of them back.
605pub 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}