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#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct PackLimits {
32 pub max_archive_bytes: u64,
34 pub max_total_bytes: u64,
36 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 pub lock: LockMode,
56 pub indexes: bool,
58 pub exclude_tables: Vec<String>,
61 pub include: Vec<PackInclude>,
65 pub encryption: Option<CipherOptions>,
68 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
90pub struct PackInclude {
91 pub path: String,
93 pub encryption: Option<CipherOptions>,
95 pub exclude_tables: Vec<String>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct PackedDatabase {
102 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 pub files: usize,
113 pub bytes: u64,
115 pub archive_bytes: u64,
117 pub databases: Vec<PackedDatabase>,
119}
120
121#[derive(Debug, Clone, Default)]
122pub struct UnpackOptions {
123 pub lock: LockMode,
124 pub encryption: Option<CipherOptions>,
126 pub include_encryption: Vec<(String, CipherOptions)>,
128 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
141fn 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
179struct Inspected {
182 archive: ZipArchive<fs::File>,
183 files: HashMap<String, usize>,
185 sizes: HashMap<String, u64>,
186}
187
188fn 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
213fn 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
330fn 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
357pub 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
420pub 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
621pub 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 current_encryption: None,
638 metadata_file,
639 entries,
640 }];
641 let mut folders: Vec<String> = Vec::new();
642 for sub in nested.unwrap_or_default() {
643 check_entry_name(&sub)?;
644 let folded = fold(&sub);
645 let overlaps = |other: &str| {
646 folded == other || folded.starts_with(&format!("{other}/")) || other.starts_with(&format!("{folded}/"))
647 };
648 if folders.iter().any(|other| overlaps(other)) {
649 return Err(refuse(format!(
650 "the manifest lists database {sub:?} twice or inside another listed one"
651 )));
652 }
653 folders.push(folded);
654 let (metadata_file, entries, _) = manifest_of(&mut inspected, &sub)?;
655 parts.push(RestorePart {
656 sub: PathBuf::from(&sub),
657 current_encryption: None,
658 encryption: options
659 .include_encryption
660 .iter()
661 .find(|(folder, _)| folder == &sub)
662 .map(|(_, keys)| keys.clone()),
663 metadata_file,
664 entries,
665 });
666 }
667 let mut listed: HashSet<String> = HashSet::new();
668 for part in &parts {
669 for entry in &part.entries {
670 let name = entry.relative.to_string_lossy().replace('\\', "/");
671 check_entry_name(&name)?;
672 if !listed.insert(fold(&name)) {
673 return Err(refuse(format!("the manifests list {name:?} twice")));
674 }
675 match inspected.sizes.get(&name) {
676 Some(size) if *size == entry.bytes => {}
677 Some(_) => {
678 return Err(refuse(format!(
679 "archive entry {name:?} does not match its manifest entry ({} bytes expected)",
680 entry.bytes
681 )))
682 }
683 None => return Err(refuse(format!("archive entry {name:?} is missing"))),
684 }
685 }
686 }
687 let all: Vec<ManifestEntry> = parts.iter().flat_map(|part| part.entries.iter().cloned()).collect();
688 restore_staged(dir, parts, &options.lock, options.max_image_bytes, |staged, _| {
689 extract(&mut inspected, &all, staged)
690 })
691}
692
693fn extract(inspected: &mut Inspected, entries: &[ManifestEntry], staged: &Path) -> Result<u64, DatabaseError> {
694 let mut total = 0u64;
695 for entry in entries {
696 let name = entry.relative.to_string_lossy().replace('\\', "/");
697 let index = inspected.files[&name];
698 let target = staged.join(&entry.relative);
699 if let Some(parent) = target.parent() {
700 fs::create_dir_all(parent)?;
701 }
702 let mut source = inspected.archive.by_index(index).map_err(zip_error)?;
703 let mut out = fs::OpenOptions::new().write(true).create_new(true).open(&target)?;
704 let written =
705 io::copy(&mut (&mut source).take(entry.bytes + 1), &mut out).map_err(|error| entry_error(&name, error))?;
706 if written != entry.bytes {
707 return Err(refuse(format!("archive entry {name:?} is not the size it declares")));
708 }
709 out.sync_all()?;
710 total += written;
711 }
712 Ok(total)
713}
714
715#[cfg(test)]
716mod tests {
717 use super::check_entry_name;
718
719 #[test]
720 fn refuses_names_that_escape_or_confuse() {
721 for bad in [
722 "",
723 "/etc/passwd",
724 "../x",
725 "a/../x",
726 "a//b",
727 "./a",
728 "a\\b",
729 "C:/x",
730 "a\u{0}b",
731 "a\nb",
732 "con",
733 "tables/NUL.txt",
734 "a. ",
735 "a.",
736 ] {
737 assert!(check_entry_name(bad).is_err(), "{bad:?} should be refused");
738 }
739 for good in [
740 "hayson.json",
741 "tables/users.jsonl",
742 "tables/users.images/ab/cd.png",
743 "gly.json",
744 ] {
745 assert!(check_entry_name(good).is_ok(), "{good:?} should be accepted");
746 }
747 }
748}