Skip to main content

hayson_engine/database/
pack.rs

1use std::collections::{BTreeSet, HashMap, HashSet};
2use std::fs;
3use std::io::{self, Read, 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, hold, owned_files, parse_manifest, read_catalog_of, refuse, restore_staged, sibling, ManifestEntry,
12    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    /// Files of the caller's own to store next to the database files (a manifest of the application's
56    /// format, say), as `(name inside the archive, bytes)`. Read back with [`read_pack_entry`].
57    pub extra: Vec<(String, Vec<u8>)>,
58    pub limits: PackLimits,
59}
60
61impl Default for PackOptions {
62    fn default() -> Self {
63        PackOptions {
64            lock: LockMode::default(),
65            indexes: false,
66            extra: Vec::new(),
67            limits: PackLimits::default(),
68        }
69    }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct PackResult {
74    pub dest: PathBuf,
75    /// Database files in the archive (not counting the manifest and the caller's extra entries).
76    pub files: usize,
77    /// Their size.
78    pub bytes: u64,
79    /// Size of the archive file.
80    pub archive_bytes: u64,
81}
82
83#[derive(Debug, Clone, Default)]
84pub struct UnpackOptions {
85    pub lock: LockMode,
86    pub limits: PackLimits,
87}
88
89fn zip_error(error: zip::result::ZipError) -> DatabaseError {
90    match error {
91        zip::result::ZipError::Io(error) => DatabaseError::Io(error),
92        other => refuse(format!("not a readable archive: {other}")),
93    }
94}
95
96/// An entry name this engine writes or extracts: relative, `/`-separated, no empty, `.` or `..`
97/// component, no control character, no Windows device name, no trailing dot or space.
98fn check_entry_name(name: &str) -> Result<(), DatabaseError> {
99    let bad = |why: &str| refuse(format!("archive entry name {name:?} is refused: {why}"));
100    if name.is_empty() || name.len() > MAX_NAME_BYTES {
101        return Err(bad("empty or too long"));
102    }
103    if name.starts_with('/') || name.contains('\\') {
104        return Err(bad("absolute path or backslash"));
105    }
106    if name.chars().any(|c| c.is_control()) {
107        return Err(bad("control character"));
108    }
109    for component in name.split('/') {
110        if component.is_empty() || component == "." || component == ".." {
111            return Err(bad("empty, \".\" or \"..\" component"));
112        }
113        if component.len() > MAX_COMPONENT_BYTES {
114            return Err(bad("component too long"));
115        }
116        if component.ends_with('.') || component.ends_with(' ') {
117            return Err(bad("component ends with a dot or a space"));
118        }
119        if component.contains(':') {
120            return Err(bad("drive letter or stream marker"));
121        }
122        let stem = component.split('.').next().unwrap_or("").trim_end().to_lowercase();
123        if DEVICE_NAMES.contains(&stem.as_str()) {
124            return Err(bad("device name"));
125        }
126    }
127    check_relative(Path::new(name))
128}
129
130fn fold(name: &str) -> String {
131    name.to_lowercase()
132}
133
134/// The result of reading an archive's central directory and judging every entry before any byte is
135/// extracted.
136struct Inspected {
137    archive: ZipArchive<fs::File>,
138    /// Non-directory entries: name → index.
139    files: HashMap<String, usize>,
140    sizes: HashMap<String, u64>,
141}
142
143fn inspect(path: &Path, limits: &PackLimits) -> Result<Inspected, DatabaseError> {
144    let meta = fs::symlink_metadata(path)
145        .map_err(|error| refuse(format!("\"{}\" cannot be read: {error}", path.display())))?;
146    if !meta.is_file() {
147        return Err(refuse(format!("\"{}\" is not a regular file", path.display())));
148    }
149    if meta.len() > limits.max_archive_bytes {
150        return Err(refuse(format!(
151            "\"{}\" is {} bytes, over the {} byte limit",
152            path.display(),
153            meta.len(),
154            limits.max_archive_bytes
155        )));
156    }
157    let mut archive = ZipArchive::new(fs::File::open(path)?).map_err(zip_error)?;
158    if archive.len() > limits.max_entries {
159        return Err(refuse(format!(
160            "the archive has {} entries, over the limit of {}",
161            archive.len(),
162            limits.max_entries
163        )));
164    }
165    let mut files = HashMap::new();
166    let mut sizes = HashMap::new();
167    let mut seen: HashSet<String> = HashSet::new();
168    let mut total = 0u64;
169    for index in 0..archive.len() {
170        let entry = archive.by_index_raw(index).map_err(zip_error)?;
171        let raw_name = entry.name().to_string();
172        let is_dir = raw_name.ends_with('/');
173        check_entry_name(raw_name.trim_end_matches('/'))?;
174        if !seen.insert(fold(raw_name.trim_end_matches('/'))) {
175            return Err(refuse(format!(
176                "archive entry {raw_name:?} appears twice (names are compared ignoring case)"
177            )));
178        }
179        if entry.encrypted() {
180            return Err(refuse(format!("archive entry {raw_name:?} is encrypted")));
181        }
182        if let Some(mode) = entry.unix_mode() {
183            let kind = mode & 0o170000;
184            if kind == 0o120000 {
185                return Err(refuse(format!("archive entry {raw_name:?} is a symbolic link")));
186            }
187            if kind != 0 && kind != 0o100000 && kind != 0o040000 {
188                return Err(refuse(format!("archive entry {raw_name:?} is not a regular file")));
189            }
190        }
191        if is_dir {
192            continue;
193        }
194        if entry.compression() != CompressionMethod::Stored {
195            return Err(refuse(format!(
196                "archive entry {raw_name:?} is compressed — packs are stored, never compressed"
197            )));
198        }
199        if entry.size() != entry.compressed_size() {
200            return Err(refuse(format!(
201                "archive entry {raw_name:?} declares inconsistent sizes"
202            )));
203        }
204        if entry.size() > limits.max_entry_bytes {
205            return Err(refuse(format!(
206                "archive entry {raw_name:?} declares {} bytes, over the {} byte limit",
207                entry.size(),
208                limits.max_entry_bytes
209            )));
210        }
211        total = total.saturating_add(entry.size());
212        if total > limits.max_total_bytes {
213            return Err(refuse(format!(
214                "the archive declares more than {} bytes in total",
215                limits.max_total_bytes
216            )));
217        }
218        sizes.insert(raw_name.clone(), entry.size());
219        files.insert(raw_name, index);
220    }
221    Ok(Inspected { archive, files, sizes })
222}
223
224fn read_entry(inspected: &mut Inspected, name: &str) -> Result<Option<Vec<u8>>, DatabaseError> {
225    let Some(&index) = inspected.files.get(name) else {
226        return Ok(None);
227    };
228    let declared = inspected.sizes[name];
229    let mut entry = inspected.archive.by_index(index).map_err(zip_error)?;
230    let mut bytes = Vec::with_capacity(declared as usize);
231    (&mut entry).take(declared + 1).read_to_end(&mut bytes)?;
232    if bytes.len() as u64 != declared {
233        return Err(refuse(format!("archive entry {name:?} is not the size it declares")));
234    }
235    Ok(Some(bytes))
236}
237
238/// Reads one entry of a pack — the caller's own manifest, say — after the same checks an unpack makes.
239/// `None` when the archive has no entry of that name.
240pub fn read_pack_entry(archive: &Path, name: &str, limits: &PackLimits) -> Result<Option<Vec<u8>>, DatabaseError> {
241    let mut inspected = inspect(archive, limits)?;
242    read_entry(&mut inspected, name)
243}
244
245/// Packs a database directory into one zip archive at `dest`: the files the database accounts for,
246/// stored without compression, plus the `hayson-backup.json` manifest and the caller's `extra` entries.
247///
248/// `dest` must not exist; the archive is built next to it and renamed into place. The directory is
249/// held under the writer lock while it is read, and a database with an unfinished unit of work is
250/// refused, as in [`super::backup_database`]. Limits are enforced here too, so what is packed can be
251/// unpacked. History kept in a database of its own is not part of this database's files.
252pub fn pack_database(dir: &Path, dest: &Path, options: &PackOptions) -> Result<PackResult, DatabaseError> {
253    let layout = Database::resolve_layout(dir, &LayoutOptions::default())?;
254    let catalog = read_catalog_of(&layout)?;
255    if dest.exists() {
256        return Err(refuse(format!("\"{}\" already exists", dest.display())));
257    }
258    let mut taken: BTreeSet<String> = BTreeSet::new();
259    taken.insert(fold(BACKUP_MANIFEST));
260    for (name, bytes) in &options.extra {
261        check_entry_name(name)?;
262        if !taken.insert(fold(name)) {
263            return Err(refuse(format!("extra entry {name:?} appears twice or is reserved")));
264        }
265        if bytes.len() as u64 > options.limits.max_entry_bytes {
266            return Err(refuse(format!("extra entry {name:?} is over the entry size limit")));
267        }
268    }
269    let partial = sibling(dest, ".partial");
270    if partial.exists() {
271        fs::remove_file(&partial)?;
272    }
273
274    let held = hold(dir, &layout, &options.lock)?;
275    let outcome = (|| -> Result<PackResult, DatabaseError> {
276        if layout.journal_path().exists() {
277            return Err(refuse(format!(
278                "\"{}\" holds an unfinished unit of work — open the database for writing once so it is finished, then pack it",
279                layout.journal_path().display()
280            )));
281        }
282        let files = owned_files(dir, &layout, &catalog, options.indexes)?;
283        if files.len() + options.extra.len() + 1 > options.limits.max_entries {
284            return Err(refuse(
285                "the database has more files than the entry limit allows".to_string(),
286            ));
287        }
288        let stored = SimpleFileOptions::default()
289            .compression_method(CompressionMethod::Stored)
290            .unix_permissions(0o644)
291            .large_file(false);
292        let mut writer = ZipWriter::new(fs::File::create(&partial)?);
293        let mut listed = Vec::new();
294        let mut total = 0u64;
295        for relative in &files {
296            let name = relative.to_string_lossy().replace('\\', "/");
297            check_entry_name(&name)?;
298            if !taken.insert(fold(&name)) {
299                return Err(refuse(format!("database file {name:?} collides with an extra entry")));
300            }
301            let mut source = fs::File::open(dir.join(relative))?;
302            writer.start_file(name.as_str(), stored).map_err(zip_error)?;
303            let bytes = io::copy(&mut (&mut source).take(options.limits.max_entry_bytes + 1), &mut writer)?;
304            if bytes > options.limits.max_entry_bytes {
305                return Err(refuse(format!(
306                    "\"{name}\" is over the {} byte entry limit",
307                    options.limits.max_entry_bytes
308                )));
309            }
310            total += bytes;
311            if total > options.limits.max_total_bytes {
312                return Err(refuse(format!(
313                    "the database is over the {} byte limit",
314                    options.limits.max_total_bytes
315                )));
316            }
317            listed.push(json!({ "path": name, "bytes": bytes }));
318        }
319        let manifest = json!({
320            "format": 1,
321            "createdAt": chrono::Utc::now().timestamp_millis(),
322            "engine": env!("CARGO_PKG_VERSION"),
323            "metadataFile": layout.metadata_file(),
324            "files": listed,
325        });
326        let mut text = serde_json::to_string_pretty(&manifest).map_err(|e| DatabaseError::Catalog(e.to_string()))?;
327        text.push('\n');
328        writer.start_file(BACKUP_MANIFEST, stored).map_err(zip_error)?;
329        writer.write_all(text.as_bytes())?;
330        for (name, bytes) in &options.extra {
331            writer.start_file(name.as_str(), stored).map_err(zip_error)?;
332            writer.write_all(bytes)?;
333        }
334        let file = writer.finish().map_err(zip_error)?;
335        file.sync_all()?;
336        let archive_bytes = file.metadata()?.len();
337        if archive_bytes > options.limits.max_archive_bytes {
338            return Err(refuse(format!(
339                "the archive would be {archive_bytes} bytes, over the {} byte limit",
340                options.limits.max_archive_bytes
341            )));
342        }
343        Ok(PackResult {
344            dest: dest.to_path_buf(),
345            files: files.len(),
346            bytes: total,
347            archive_bytes,
348        })
349    })();
350    drop(held);
351    match outcome {
352        Ok(result) => {
353            fs::rename(&partial, dest)?;
354            Ok(result)
355        }
356        Err(error) => {
357            let _ = fs::remove_file(&partial);
358            Err(error)
359        }
360    }
361}
362
363/// Unpacks an archive made by [`pack_database`] into `dir`, replacing the database that is there,
364/// with the recoverable procedure of [`super::restore_database`] (stage, verify, swap, roll back).
365///
366/// The archive is judged before anything is written: size and entry-count limits, names (no `..`,
367/// absolute or drive paths, backslashes, control characters, device names, duplicates ignoring case),
368/// no symbolic links, no compressed or encrypted entries, declared sizes against the limits and against
369/// what each entry really holds. Entries the manifest does not list — the caller's extras — are not
370/// extracted.
371pub fn unpack_database(archive: &Path, dir: &Path, options: &UnpackOptions) -> Result<RestoreResult, DatabaseError> {
372    let mut inspected = inspect(archive, &options.limits)?;
373    let manifest = read_entry(&mut inspected, BACKUP_MANIFEST)?.ok_or_else(|| {
374        refuse(format!(
375            "the archive has no \"{BACKUP_MANIFEST}\" — it is not a hayson pack"
376        ))
377    })?;
378    let text = String::from_utf8(manifest).map_err(|_| refuse(format!("\"{BACKUP_MANIFEST}\" is not UTF-8")))?;
379    let (metadata_file, entries) = parse_manifest(&text, BACKUP_MANIFEST)?;
380    let mut listed: HashSet<String> = HashSet::new();
381    for entry in &entries {
382        let name = entry.relative.to_string_lossy().replace('\\', "/");
383        check_entry_name(&name)?;
384        if !listed.insert(fold(&name)) {
385            return Err(refuse(format!("the manifest lists {name:?} twice")));
386        }
387        match inspected.sizes.get(&name) {
388            Some(size) if *size == entry.bytes => {}
389            Some(_) => {
390                return Err(refuse(format!(
391                    "archive entry {name:?} does not match its manifest entry ({} bytes expected)",
392                    entry.bytes
393                )))
394            }
395            None => return Err(refuse(format!("archive entry {name:?} is missing"))),
396        }
397    }
398    restore_staged(dir, &metadata_file, &entries, &options.lock, |staged| {
399        extract(&mut inspected, &entries, staged)
400    })
401}
402
403fn extract(inspected: &mut Inspected, entries: &[ManifestEntry], staged: &Path) -> Result<u64, DatabaseError> {
404    let mut total = 0u64;
405    for entry in entries {
406        let name = entry.relative.to_string_lossy().replace('\\', "/");
407        let index = inspected.files[&name];
408        let target = staged.join(&entry.relative);
409        if let Some(parent) = target.parent() {
410            fs::create_dir_all(parent)?;
411        }
412        let mut source = inspected.archive.by_index(index).map_err(zip_error)?;
413        let mut out = fs::OpenOptions::new().write(true).create_new(true).open(&target)?;
414        let written = io::copy(&mut (&mut source).take(entry.bytes + 1), &mut out)?;
415        if written != entry.bytes {
416            return Err(refuse(format!("archive entry {name:?} is not the size it declares")));
417        }
418        out.sync_all()?;
419        total += written;
420    }
421    Ok(total)
422}
423
424#[cfg(test)]
425mod tests {
426    use super::check_entry_name;
427
428    #[test]
429    fn refuses_names_that_escape_or_confuse() {
430        for bad in [
431            "",
432            "/etc/passwd",
433            "../x",
434            "a/../x",
435            "a//b",
436            "./a",
437            "a\\b",
438            "C:/x",
439            "a\u{0}b",
440            "a\nb",
441            "con",
442            "tables/NUL.txt",
443            "a. ",
444            "a.",
445        ] {
446            assert!(check_entry_name(bad).is_err(), "{bad:?} should be refused");
447        }
448        for good in [
449            "hayson.json",
450            "tables/users.jsonl",
451            "tables/users.images/ab/cd.png",
452            "gly.json",
453        ] {
454            assert!(check_entry_name(good).is_ok(), "{good:?} should be accepted");
455        }
456    }
457}