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;
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/// What a pack may hold and what an unpack accepts. The same limits guard both directions, so a
26/// pack this engine writes is one it reads back.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct PackLimits {
29    /// Size of the archive file itself.
30    pub max_archive_bytes: u64,
31    /// Sum of the declared sizes of every entry.
32    pub max_total_bytes: u64,
33    /// Declared size of a single entry.
34    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    /// How the writer lock is taken for the length of the read. As in [`super::BackupOptions`].
52    pub lock: LockMode,
53    /// Also pack the `<table>.idx` snapshots (caches; off by default).
54    pub indexes: bool,
55    /// Tables whose data is left out of the archive and its manifest — see
56    /// [`super::BackupOptions::exclude_tables`].
57    pub exclude_tables: Vec<String>,
58    /// Files of the caller's own to store next to the database files (a manifest of the application's
59    /// format, say), as `(name inside the archive, bytes)`. Read back with [`read_pack_entry`].
60    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    /// Database files in the archive (not counting the manifest and the caller's extra entries).
80    pub files: usize,
81    /// Their size.
82    pub bytes: u64,
83    /// Size of the archive file.
84    pub archive_bytes: u64,
85}
86
87#[derive(Debug, Clone, Default)]
88pub struct UnpackOptions {
89    pub lock: LockMode,
90    /// Refuses a pack holding an image file larger than this, after extraction but before anything
91    /// in the target is replaced.
92    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
103/// An entry name this engine writes or extracts: relative, `/`-separated, no empty, `.` or `..`
104/// component, no control character, no Windows device name, no trailing dot or space.
105fn 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
141/// The result of reading an archive's central directory and judging every entry before any byte is
142/// extracted.
143struct Inspected {
144    archive: ZipArchive<fs::File>,
145    /// Non-directory entries: name → index.
146    files: HashMap<String, usize>,
147    sizes: HashMap<String, u64>,
148}
149
150/// How many entries the end-of-central-directory record declares, after making sure that record is
151/// the last thing in the file: the zip reader tolerates bytes after it, and collapses entries that
152/// share a name, so both are read off the record itself.
153fn 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
175/// The name the local header of the entry at `header_start` carries.
176fn 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
306/// Reads one entry of a pack — the caller's own manifest, say — after the same checks an unpack makes.
307/// `None` when the archive has no entry of that name.
308pub 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
313/// Packs a database directory into one zip archive at `dest`: the files the database accounts for,
314/// stored without compression, plus the `hayson-backup.json` manifest and the caller's `extra` entries.
315///
316/// `dest` must not exist; the archive is built next to it and renamed into place. The directory is
317/// held under the writer lock while it is read, and a database with an unfinished unit of work is
318/// refused, as in [`super::backup_database`]. Limits are enforced here too, so what is packed can be
319/// unpacked. History kept in a database of its own is not part of this database's files.
320pub 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
438/// Unpacks an archive made by [`pack_database`] into `dir`, replacing the database that is there,
439/// with the recoverable procedure of [`super::restore_database`] (stage, verify, swap, roll back).
440///
441/// The archive is judged before anything is written: size and entry-count limits, names (no `..`,
442/// absolute or drive paths, backslashes, control characters, device names, duplicates ignoring case),
443/// no symbolic links, no compressed or encrypted entries, declared sizes against the limits and against
444/// what each entry really holds. Entries the manifest does not list — the caller's extras — are not
445/// extracted.
446pub 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}