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#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct PackLimits {
29 pub max_archive_bytes: u64,
31 pub max_total_bytes: u64,
33 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 pub lock: LockMode,
53 pub indexes: bool,
55 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 pub files: usize,
77 pub bytes: u64,
79 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
96fn 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
134struct Inspected {
137 archive: ZipArchive<fs::File>,
138 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
238pub 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
245pub 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
363pub 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}