1use std::{
17 ffi::OsString,
18 fs,
19 io::Read,
20 path::{Path, PathBuf},
21 process::Command,
22 time::{SystemTime, UNIX_EPOCH},
23};
24
25use anyhow::{bail, Context, Result};
26use serde_json::json;
27use sha2::{Digest, Sha256};
28use sqlx::postgres::{PgConnectOptions, PgSslMode};
29
30use crate::{backupcrypt::BackupKey, config::Config};
31
32const MANIFEST_FILE: &str = "manifest.json";
33const DUMP_FILE: &str = "db.dump";
34const VAULT_DIR: &str = "vault";
35const MANIFEST_FORMAT: &str = "teamcraft-backup/1";
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct Manifest {
39 created_at_unix: u64,
40 app_version: String,
41 vault_objects: u64,
42 db_dump_sha256: String,
44 encryption: Option<serde_json::Value>,
47}
48
49impl Manifest {
50 fn to_json(&self) -> String {
51 let mut value = json!({
52 "format": MANIFEST_FORMAT,
53 "created_at_unix": self.created_at_unix,
54 "app_version": self.app_version,
55 "vault_objects": self.vault_objects,
56 "db_dump_sha256": self.db_dump_sha256,
57 });
58 if let Some(encryption) = &self.encryption {
59 value["encryption"] = encryption.clone();
60 }
61 serde_json::to_string_pretty(&value).expect("the manifest is always serializable")
62 }
63
64 fn from_json(text: &str) -> Result<Self> {
65 let value: serde_json::Value =
66 serde_json::from_str(text).context("manifest.json is not valid JSON")?;
67 let format = value
68 .get("format")
69 .and_then(serde_json::Value::as_str)
70 .context("manifest.json has no 'format'")?;
71 if format != MANIFEST_FORMAT {
72 bail!("unsupported backup format '{format}' (expected '{MANIFEST_FORMAT}')");
73 }
74 let string_field = |name: &str| -> Result<String> {
75 value
76 .get(name)
77 .and_then(serde_json::Value::as_str)
78 .filter(|value| !value.is_empty())
79 .map(str::to_string)
80 .with_context(|| format!("manifest.json has no '{name}'"))
81 };
82 let number_field = |name: &str| -> Result<u64> {
83 value
84 .get(name)
85 .and_then(serde_json::Value::as_u64)
86 .with_context(|| format!("manifest.json has no '{name}'"))
87 };
88 let db_dump_sha256 = string_field("db_dump_sha256")?.to_ascii_lowercase();
89 if let Err(error) = validate_hash(&db_dump_sha256) {
90 return Err(error.context("manifest.json: 'db_dump_sha256' is invalid"));
91 }
92 Ok(Manifest {
93 created_at_unix: number_field("created_at_unix")?,
94 app_version: string_field("app_version")?,
95 vault_objects: number_field("vault_objects")?,
96 db_dump_sha256,
97 encryption: value.get("encryption").filter(|v| !v.is_null()).cloned(),
98 })
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct BackupSummary {
105 pub db_dump_bytes: u64,
106 pub db_dump_sha256: String,
107 pub vault_objects: usize,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct RestoreSummary {
113 pub vault_objects: usize,
114 pub copied: usize,
115 pub skipped: usize,
116}
117
118struct DatabaseArgs {
121 host: String,
122 port: u16,
123 username: String,
124 database: String,
125 password: Option<String>,
126 ssl_mode: &'static str,
127}
128
129impl DatabaseArgs {
130 fn new(options: &PgConnectOptions, password: Option<String>) -> Self {
131 let username = options.get_username();
132 DatabaseArgs {
133 host: options.get_host().to_string(),
134 port: options.get_port(),
135 username: username.to_string(),
136 database: options.get_database().unwrap_or(username).to_string(),
137 password,
138 ssl_mode: ssl_mode_str(options.get_ssl_mode()),
139 }
140 }
141
142 fn from_config(config: &Config) -> Self {
143 Self::new(&config.database, config.database_password.clone())
144 }
145}
146
147fn ssl_mode_str(mode: PgSslMode) -> &'static str {
149 match mode {
150 PgSslMode::Disable => "disable",
151 PgSslMode::Allow => "allow",
152 PgSslMode::Prefer => "prefer",
153 PgSslMode::Require => "require",
154 PgSslMode::VerifyCa => "verify-ca",
155 PgSslMode::VerifyFull => "verify-full",
156 }
157}
158
159pub async fn backup(config: &Config, dir: &Path) -> Result<BackupSummary> {
167 backup_with(config, dir, None).await
168}
169
170pub async fn backup_encrypted(
174 config: &Config,
175 dir: &Path,
176 passphrase: &str,
177) -> Result<BackupSummary> {
178 let key = BackupKey::new(passphrase)?;
179 backup_with(config, dir, Some(&key)).await
180}
181
182async fn backup_with(
183 config: &Config,
184 dir: &Path,
185 key: Option<&BackupKey>,
186) -> Result<BackupSummary> {
187 let created = prepare_target(dir)?;
188 match write_backup(config, dir, key).await {
189 Ok(summary) => Ok(summary),
190 Err(error) => {
191 discard_partial(dir, created);
192 Err(error.context("no backup was written"))
193 }
194 }
195}
196
197async fn write_backup(
198 config: &Config,
199 dir: &Path,
200 key: Option<&BackupKey>,
201) -> Result<BackupSummary> {
202 let database = DatabaseArgs::from_config(config);
203 let dump = dir.join(DUMP_FILE);
204 let plain_dump = match key {
205 Some(_) => dir.join(format!("{DUMP_FILE}.plain")),
206 None => dump.clone(),
207 };
208 run(dump_command(&database, &plain_dump), "pg_dump")?;
209 if !plain_dump.is_file() {
210 bail!("pg_dump did not create '{}'", plain_dump.display());
211 }
212 let (db_dump_sha256, db_dump_bytes) = match key {
213 Some(key) => {
214 let result = crate::backupcrypt::encrypt_file(key, &plain_dump, &dump);
215 let _ = fs::remove_file(&plain_dump);
216 result?
217 }
218 None => {
219 let bytes = fs::metadata(&dump)
220 .with_context(|| format!("cannot read '{}'", dump.display()))?
221 .len();
222 (sha256_file(&dump)?, bytes)
223 }
224 };
225
226 let referenced = referenced_hashes(config).await?;
229 let target_objects = dir.join(VAULT_DIR).join("objects");
230 fs::create_dir_all(&target_objects)
231 .with_context(|| format!("cannot create '{}'", target_objects.display()))?;
232 let copied = copy_checked_objects(&config.vault_dir.join("objects"), &target_objects, key)?;
233 let missing: Vec<&String> = referenced
234 .iter()
235 .filter(|hash| !copied.contains(hash.as_str()))
236 .collect();
237 if let Some(first) = missing.first() {
238 bail!(
239 "{} file content(s) the database refers to are missing from the vault (e.g. {first}); \
240 check the vault with `teamcraft vault check`",
241 missing.len()
242 );
243 }
244
245 let manifest = Manifest {
246 created_at_unix: now_unix(),
247 app_version: env!("CARGO_PKG_VERSION").to_string(),
248 vault_objects: copied.len() as u64,
249 db_dump_sha256: db_dump_sha256.clone(),
250 encryption: key.map(BackupKey::manifest_entry),
251 };
252 let manifest_path = dir.join(MANIFEST_FILE);
253 let partial = dir.join(format!("{MANIFEST_FILE}.partial"));
254 fs::write(&partial, manifest.to_json())
255 .with_context(|| format!("cannot write '{}'", partial.display()))?;
256 fs::rename(&partial, &manifest_path)
257 .with_context(|| format!("cannot write '{}'", manifest_path.display()))?;
258
259 Ok(BackupSummary {
260 db_dump_bytes,
261 db_dump_sha256,
262 vault_objects: copied.len(),
263 })
264}
265
266async fn referenced_hashes(config: &Config) -> Result<Vec<String>> {
268 let pool = crate::db::connect(config)
269 .await
270 .context("cannot connect to the database to list the referenced file contents")?;
271 let hashes = crate::services::files::all_hashes(&pool).await;
272 pool.close().await;
273 hashes.map_err(|e| anyhow::anyhow!("cannot list the referenced file contents: {e}"))
274}
275
276fn copy_checked_objects(
279 source_objects: &Path,
280 target_objects: &Path,
281 key: Option<&BackupKey>,
282) -> Result<std::collections::HashSet<String>> {
283 let mut copied = std::collections::HashSet::new();
284 for object in collect_objects(source_objects)? {
285 let target = object_path_under(target_objects, &object.hash)?;
286 if fs::symlink_metadata(&target).is_ok() {
287 bail!(
288 "backup target already contains the object '{}'",
289 object.hash
290 );
291 }
292 if let Some(parent) = target.parent() {
293 fs::create_dir_all(parent)
294 .with_context(|| format!("cannot create '{}'", parent.display()))?;
295 }
296 let actual = match key {
297 Some(key) => crate::backupcrypt::encrypt_file(key, &object.path, &target)?.0,
299 None => {
300 fs::copy(&object.path, &target).with_context(|| {
301 format!(
302 "cannot copy vault object '{}' to '{}'",
303 object.path.display(),
304 target.display()
305 )
306 })?;
307 sha256_file(&target)?
308 }
309 };
310 if actual != object.hash {
311 bail!(
312 "vault object {} is corrupt (its content is {actual}); restore it from an \
313 older backup or check the vault with `teamcraft vault check --verify-hashes`",
314 object.hash
315 );
316 }
317 copied.insert(object.hash);
318 }
319 Ok(copied)
320}
321
322fn discard_partial(dir: &Path, created: bool) {
325 if created {
326 let _ = fs::remove_dir_all(dir);
327 return;
328 }
329 if let Ok(entries) = fs::read_dir(dir) {
330 for entry in entries.flatten() {
331 let path = entry.path();
332 let _ = if path.is_dir() {
333 fs::remove_dir_all(&path)
334 } else {
335 fs::remove_file(&path)
336 };
337 }
338 }
339}
340
341pub async fn restore(config: &Config, dir: &Path, force: bool) -> Result<RestoreSummary> {
344 restore_with(config, dir, force, None).await
345}
346
347pub async fn restore_with(
351 config: &Config,
352 dir: &Path,
353 force: bool,
354 passphrase: Option<&str>,
355) -> Result<RestoreSummary> {
356 let (manifest, key) = verify_backup_with(dir, passphrase)?;
357 ensure_database_empty(config, force).await?;
358 let database = DatabaseArgs::from_config(config);
359 let mut dump = dir.join(DUMP_FILE);
360 let mut temporary = None;
361 if let Some(key) = &key {
362 let tmp = config.vault_dir.join("tmp");
363 fs::create_dir_all(&tmp).with_context(|| format!("cannot create '{}'", tmp.display()))?;
364 let plain = tmp.join(format!("restore-{}.dump", std::process::id()));
365 let _ = fs::remove_file(&plain);
366 let mut out = std::io::BufWriter::new(
367 fs::File::create_new(&plain)
368 .with_context(|| format!("cannot create '{}'", plain.display()))?,
369 );
370 if let Err(e) = crate::backupcrypt::decrypt_file(key, &dump, &mut out) {
371 drop(out);
372 let _ = fs::remove_file(&plain);
373 return Err(e);
374 }
375 drop(out);
376 dump = plain.clone();
377 temporary = Some(plain);
378 }
379 let restored = run(restore_command(&database, &dump), "pg_restore");
380 if let Some(plain) = &temporary {
381 let _ = fs::remove_file(plain);
382 }
383 if let Err(error) = restored {
384 bail!(
385 "{error:#} – the database is unchanged (the restore runs in one transaction); \
386 restore a backup that is older than the database (e.g. from before an upgrade) \
387 into a fresh, empty database"
388 );
389 }
390
391 let target_objects = config.vault_dir.join("objects");
392 fs::create_dir_all(&target_objects)
393 .with_context(|| format!("cannot create '{}'", target_objects.display()))?;
394 let stats = copy_objects_with(
395 &dir.join(VAULT_DIR).join("objects"),
396 &target_objects,
397 true,
398 key.as_ref(),
399 )?;
400
401 Ok(RestoreSummary {
402 vault_objects: manifest.vault_objects as usize,
403 copied: stats.copied,
404 skipped: stats.skipped,
405 })
406}
407
408async fn ensure_database_empty(config: &Config, force: bool) -> Result<()> {
409 if force {
410 return Ok(());
411 }
412 let pool = match crate::db::connect(config).await {
413 Ok(pool) => pool,
414 Err(error) => bail!(
415 "cannot connect to the database to check whether it is empty (does it exist?): {error:#}"
416 ),
417 };
418 let tables = sqlx::query_scalar::<_, i64>(
419 "SELECT COUNT(*) FROM pg_tables
420 WHERE schemaname NOT IN ('pg_catalog', 'information_schema')",
421 )
422 .fetch_one(&pool)
423 .await
424 .context("cannot inspect the database");
425 pool.close().await;
426 let tables = tables?;
427 if tables > 0 {
428 bail!(
429 "the database already contains {tables} table(s); run the restore with --force to replace them"
430 );
431 }
432 Ok(())
433}
434
435fn prepare_target(dir: &Path) -> Result<bool> {
438 if dir.exists() {
439 if !dir.is_dir() {
440 bail!("backup target '{}' is not a directory", dir.display());
441 }
442 let mut entries = fs::read_dir(dir)
443 .with_context(|| format!("cannot read backup target '{}'", dir.display()))?;
444 if entries
445 .next()
446 .transpose()
447 .with_context(|| format!("cannot read backup target '{}'", dir.display()))?
448 .is_some()
449 {
450 bail!("backup target '{}' is not empty", dir.display());
451 }
452 Ok(false)
453 } else {
454 fs::create_dir_all(dir)
455 .with_context(|| format!("cannot create backup target '{}'", dir.display()))?;
456 Ok(true)
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
461struct ObjectFile {
462 hash: String,
463 path: PathBuf,
464}
465
466#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
467struct CopyStats {
468 copied: usize,
469 skipped: usize,
470}
471
472fn read_sorted(dir: &Path) -> Result<Vec<fs::DirEntry>> {
473 let mut entries = fs::read_dir(dir)
474 .with_context(|| format!("cannot read '{}'", dir.display()))?
475 .collect::<std::io::Result<Vec<_>>>()
476 .with_context(|| format!("cannot read '{}'", dir.display()))?;
477 entries.sort_by_key(|entry| entry.file_name());
478 Ok(entries)
479}
480
481fn hex_folder(entry: &fs::DirEntry) -> Result<String> {
482 let name = entry.file_name();
483 let name = name
484 .to_str()
485 .with_context(|| format!("non-UTF-8 name in the vault: '{}'", entry.path().display()))?;
486 if name.len() != 2 || !name.bytes().all(is_lower_hex) {
487 bail!(
488 "unexpected entry '{}': not a two-digit hex folder",
489 entry.path().display()
490 );
491 }
492 Ok(name.to_string())
493}
494
495fn collect_objects(objects_root: &Path) -> Result<Vec<ObjectFile>> {
496 if !objects_root.exists() {
497 return Ok(Vec::new());
498 }
499 if !objects_root.is_dir() {
500 bail!(
501 "vault objects path '{}' is not a directory",
502 objects_root.display()
503 );
504 }
505 let mut objects = Vec::new();
506 for first in read_sorted(objects_root)? {
507 if !first.file_type()?.is_dir() {
508 bail!(
509 "unexpected file '{}' in the vault objects directory",
510 first.path().display()
511 );
512 }
513 let first_prefix = hex_folder(&first)?;
514 for second in read_sorted(&first.path())? {
515 if !second.file_type()?.is_dir() {
516 bail!(
517 "unexpected file '{}' in the vault objects directory",
518 second.path().display()
519 );
520 }
521 let second_prefix = hex_folder(&second)?;
522 for entry in read_sorted(&second.path())? {
523 if !entry.file_type()?.is_file() {
524 bail!(
525 "'{}' is not a regular file; refusing to copy it",
526 entry.path().display()
527 );
528 }
529 let name = entry.file_name();
530 let hash = name
531 .to_str()
532 .with_context(|| {
533 format!("non-UTF-8 object name in '{}'", entry.path().display())
534 })?
535 .to_string();
536 if let Err(error) = validate_hash(&hash) {
537 return Err(
538 error.context(format!("unexpected object '{}'", entry.path().display()))
539 );
540 }
541 let expected_prefix = format!("{first_prefix}{second_prefix}");
542 if !hash.starts_with(expected_prefix.as_str()) {
543 bail!(
544 "object '{}' is not stored in its content-addressed folder",
545 entry.path().display()
546 );
547 }
548 objects.push(ObjectFile {
549 hash,
550 path: entry.path(),
551 });
552 }
553 }
554 }
555 Ok(objects)
556}
557
558#[cfg(test)]
559fn copy_objects(
560 source_objects: &Path,
561 target_objects: &Path,
562 skip_existing: bool,
563) -> Result<CopyStats> {
564 copy_objects_with(source_objects, target_objects, skip_existing, None)
565}
566
567fn copy_objects_with(
569 source_objects: &Path,
570 target_objects: &Path,
571 skip_existing: bool,
572 key: Option<&BackupKey>,
573) -> Result<CopyStats> {
574 let mut stats = CopyStats::default();
575 for object in collect_objects(source_objects)? {
576 let target = object_path_under(target_objects, &object.hash)?;
577 match fs::symlink_metadata(&target) {
578 Ok(meta) if meta.file_type().is_file() => {
579 if skip_existing {
580 stats.skipped += 1;
581 continue;
582 }
583 bail!(
584 "backup target already contains the object '{}'",
585 object.hash
586 );
587 }
588 Ok(_) => bail!("'{}' exists and is not a regular file", target.display()),
589 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
590 Err(error) => {
591 return Err(error)
592 .with_context(|| format!("cannot inspect '{}'", target.display()));
593 }
594 }
595 if let Some(parent) = target.parent() {
596 fs::create_dir_all(parent)
597 .with_context(|| format!("cannot create '{}'", parent.display()))?;
598 }
599 if let Some(key) = key {
600 let partial = target.with_extension("partial");
602 let _ = fs::remove_file(&partial);
603 let mut out = std::io::BufWriter::new(
604 fs::File::create_new(&partial)
605 .with_context(|| format!("cannot create '{}'", partial.display()))?,
606 );
607 let sha = crate::backupcrypt::decrypt_file(key, &object.path, &mut out);
608 drop(out);
609 match sha {
610 Ok(sha) if sha == object.hash => fs::rename(&partial, &target)
611 .with_context(|| format!("cannot write '{}'", target.display()))?,
612 Ok(sha) => {
613 let _ = fs::remove_file(&partial);
614 bail!(
615 "vault object {} is corrupt (its content is {sha})",
616 object.hash
617 );
618 }
619 Err(e) => {
620 let _ = fs::remove_file(&partial);
621 return Err(e);
622 }
623 }
624 } else {
625 fs::copy(&object.path, &target).with_context(|| {
626 format!(
627 "cannot copy vault object '{}' to '{}'",
628 object.path.display(),
629 target.display()
630 )
631 })?;
632 }
633 stats.copied += 1;
634 }
635 Ok(stats)
636}
637
638fn validate_hash(hash: &str) -> Result<()> {
639 if hash.len() != 64 || !hash.bytes().all(is_lower_hex) {
640 bail!("'{hash}' is not a lower-case SHA-256 hash");
641 }
642 Ok(())
643}
644
645fn is_lower_hex(byte: u8) -> bool {
646 matches!(byte, b'0'..=b'9' | b'a'..=b'f')
647}
648
649fn object_path_under(objects_root: &Path, hash: &str) -> Result<PathBuf> {
650 validate_hash(hash)?;
651 Ok(objects_root.join(&hash[0..2]).join(&hash[2..4]).join(hash))
652}
653
654fn sha256_file(path: &Path) -> Result<String> {
655 let mut file =
656 fs::File::open(path).with_context(|| format!("cannot read '{}'", path.display()))?;
657 let mut hasher = Sha256::new();
658 let mut buffer = [0u8; 64 * 1024];
659 loop {
660 let read = file
661 .read(&mut buffer)
662 .with_context(|| format!("cannot read '{}'", path.display()))?;
663 if read == 0 {
664 break;
665 }
666 hasher.update(&buffer[..read]);
667 }
668 Ok(hex::encode(hasher.finalize()))
669}
670
671fn now_unix() -> u64 {
672 SystemTime::now()
673 .duration_since(UNIX_EPOCH)
674 .map(|duration| duration.as_secs())
675 .unwrap_or_default()
676}
677
678pub fn is_encrypted(dir: &Path) -> Result<bool> {
680 let manifest_path = dir.join(MANIFEST_FILE);
681 let text = fs::read_to_string(&manifest_path)
682 .with_context(|| format!("cannot read '{}'", manifest_path.display()))?;
683 Ok(Manifest::from_json(&text)?.encryption.is_some())
684}
685
686#[cfg(test)]
687fn verify_backup(dir: &Path) -> Result<Manifest> {
688 verify_backup_with(dir, None).map(|(manifest, _)| manifest)
689}
690
691fn backup_file_sha(path: &Path, key: Option<&BackupKey>) -> Result<String> {
694 match key {
695 Some(key) => crate::backupcrypt::decrypt_file(key, path, &mut std::io::sink()),
696 None => sha256_file(path),
697 }
698}
699
700fn verify_backup_with(
703 dir: &Path,
704 passphrase: Option<&str>,
705) -> Result<(Manifest, Option<BackupKey>)> {
706 let manifest_path = dir.join(MANIFEST_FILE);
707 let text = fs::read_to_string(&manifest_path)
708 .with_context(|| format!("cannot read '{}'", manifest_path.display()))?;
709 let manifest = Manifest::from_json(&text)?;
710 let key = match (&manifest.encryption, passphrase) {
711 (Some(entry), Some(passphrase)) => Some(BackupKey::from_manifest(passphrase, entry)?),
712 (Some(_), None) => bail!(
713 "the backup is encrypted: set TEAMCRAFT_BACKUP_PASSPHRASE (or _FILE) or enter the passphrase"
714 ),
715 (None, _) => None,
716 };
717
718 let dump = dir.join(DUMP_FILE);
719 let metadata =
720 fs::metadata(&dump).with_context(|| format!("cannot read '{}'", dump.display()))?;
721 if !metadata.is_file() {
722 bail!("'{}' is not a file", dump.display());
723 }
724 let actual = backup_file_sha(&dump, key.as_ref())?;
725 if actual != manifest.db_dump_sha256 {
726 bail!(
727 "database dump checksum mismatch: manifest {}, file {}",
728 manifest.db_dump_sha256,
729 actual
730 );
731 }
732
733 let objects_root = dir.join(VAULT_DIR).join("objects");
734 let objects = collect_objects(&objects_root)?;
735 if objects.len() as u64 != manifest.vault_objects {
736 bail!(
737 "backup contains {} vault object(s), the manifest says {}",
738 objects.len(),
739 manifest.vault_objects
740 );
741 }
742 for object in &objects {
743 let actual = backup_file_sha(&object.path, key.as_ref())?;
744 if actual != object.hash {
745 bail!(
746 "vault object {} is corrupt (file content is {})",
747 object.hash,
748 actual
749 );
750 }
751 }
752 Ok((manifest, key))
753}
754
755fn connection_args(database: &DatabaseArgs) -> Vec<OsString> {
756 vec![
757 OsString::from("--host"),
758 OsString::from(database.host.as_str()),
759 OsString::from("--port"),
760 OsString::from(database.port.to_string()),
761 OsString::from("--username"),
762 OsString::from(database.username.as_str()),
763 OsString::from("--dbname"),
764 OsString::from(database.database.as_str()),
765 ]
766}
767
768fn dump_args(database: &DatabaseArgs, target: &Path) -> Vec<OsString> {
769 let mut args = vec![
770 OsString::from("--format=custom"),
771 OsString::from("--no-password"),
772 ];
773 args.extend(connection_args(database));
774 args.push(OsString::from("--file"));
775 args.push(target.as_os_str().to_os_string());
776 args
777}
778
779fn restore_args(database: &DatabaseArgs, dump: &Path) -> Vec<OsString> {
780 let mut args = vec![
781 OsString::from("--clean"),
782 OsString::from("--if-exists"),
783 OsString::from("--no-owner"),
784 OsString::from("--single-transaction"),
788 OsString::from("--no-password"),
789 ];
790 args.extend(connection_args(database));
791 args.push(dump.as_os_str().to_os_string());
792 args
793}
794
795fn apply_connection_env(command: &mut Command, database: &DatabaseArgs) {
796 command.env("PGSSLMODE", database.ssl_mode);
797 if let Some(password) = &database.password {
798 command.env("PGPASSWORD", password.as_str());
799 }
800}
801
802fn dump_command(database: &DatabaseArgs, target: &Path) -> Command {
803 let mut command = Command::new("pg_dump");
804 command.args(dump_args(database, target));
805 apply_connection_env(&mut command, database);
806 command
807}
808
809fn restore_command(database: &DatabaseArgs, dump: &Path) -> Command {
810 let mut command = Command::new("pg_restore");
811 command.args(restore_args(database, dump));
812 apply_connection_env(&mut command, database);
813 command
814}
815
816fn run(mut command: Command, name: &str) -> Result<()> {
817 let status = command
818 .status()
819 .with_context(|| format!("cannot run {name}: is it installed and on PATH?"))?;
820 if !status.success() {
821 bail!("{name} failed ({status})");
822 }
823 Ok(())
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use std::ffi::OsStr;
830
831 fn temp_dir(name: &str) -> PathBuf {
832 let dir =
833 std::env::temp_dir().join(format!("teamcraft-backup-{name}-{}", uuid::Uuid::new_v4()));
834 fs::create_dir_all(&dir).unwrap();
835 dir
836 }
837
838 fn sha256_bytes(bytes: &[u8]) -> String {
839 hex::encode(Sha256::digest(bytes))
840 }
841
842 fn write_object(objects_root: &Path, content: &[u8]) -> String {
843 let hash = sha256_bytes(content);
844 let path = object_path_under(objects_root, &hash).unwrap();
845 fs::create_dir_all(path.parent().unwrap()).unwrap();
846 fs::write(&path, content).unwrap();
847 hash
848 }
849
850 fn database_args() -> DatabaseArgs {
851 DatabaseArgs {
852 host: "db.example.org".into(),
853 port: 5433,
854 username: "teamcraft".into(),
855 database: "plm".into(),
856 password: Some("pw-secret".into()),
857 ssl_mode: "require",
858 }
859 }
860
861 fn arg_strings(command: &Command) -> Vec<String> {
862 command
863 .get_args()
864 .map(|arg| arg.to_string_lossy().into_owned())
865 .collect()
866 }
867
868 fn env_value<'a>(command: &'a Command, name: &str) -> Option<&'a OsStr> {
869 command
870 .get_envs()
871 .find(|(key, _)| *key == OsStr::new(name))
872 .and_then(|(_, value)| value)
873 }
874
875 #[test]
876 fn manifest_round_trip_and_validation() {
877 let manifest = Manifest {
878 created_at_unix: 1_762_000_000,
879 app_version: "0.1.0".into(),
880 vault_objects: 3,
881 db_dump_sha256: sha256_bytes(b"dump"),
882 encryption: None,
883 };
884 assert_eq!(Manifest::from_json(&manifest.to_json()).unwrap(), manifest);
885 assert!(Manifest::from_json("{").is_err());
886 let invalid_hash = format!(
887 r#"{{"format":"{MANIFEST_FORMAT}","created_at_unix":1,"app_version":"0.1.0","vault_objects":0,"db_dump_sha256":"../escape"}}"#
888 );
889 assert!(Manifest::from_json(&invalid_hash).is_err());
890 let other_format = manifest.to_json().replace(MANIFEST_FORMAT, "other/1");
891 assert!(Manifest::from_json(&other_format).is_err());
892 let no_count = format!(
893 r#"{{"format":"{MANIFEST_FORMAT}","created_at_unix":1,"app_version":"0.1.0","db_dump_sha256":"{}"}}"#,
894 sha256_bytes(b"dump")
895 );
896 assert!(Manifest::from_json(&no_count).is_err());
897 }
898
899 #[test]
900 fn database_options_are_split_into_arguments() {
901 let options = PgConnectOptions::new()
902 .host("db.example.org")
903 .port(5433)
904 .username("teamcraft")
905 .password("pw-secret")
906 .database("plm")
907 .ssl_mode(PgSslMode::VerifyFull);
908 let database = DatabaseArgs::new(&options, Some("pw-secret".into()));
909 assert_eq!(database.host.as_str(), "db.example.org");
910 assert_eq!(database.port, 5433);
911 assert_eq!(database.username.as_str(), "teamcraft");
912 assert_eq!(database.database.as_str(), "plm");
913 assert_eq!(database.password.as_deref(), Some("pw-secret"));
914 assert_eq!(database.ssl_mode, "verify-full");
915 }
916
917 #[test]
918 fn commands_keep_the_password_out_of_the_arguments() {
919 let database = database_args();
920 let dump = Path::new("/backup/db.dump");
921 let command = dump_command(&database, dump);
922 assert_eq!(command.get_program().to_string_lossy(), "pg_dump");
923 let args = arg_strings(&command);
924 for expected in [
925 "--format=custom",
926 "--no-password",
927 "--host",
928 "db.example.org",
929 "--port",
930 "5433",
931 "--username",
932 "teamcraft",
933 "--dbname",
934 "plm",
935 "--file",
936 "/backup/db.dump",
937 ] {
938 assert!(
939 args.iter().any(|arg| arg.as_str() == expected),
940 "missing {expected}: {args:?}"
941 );
942 }
943 assert!(!args.iter().any(|arg| arg.contains("pw-secret")));
944 assert_eq!(
945 env_value(&command, "PGPASSWORD"),
946 Some(OsStr::new("pw-secret"))
947 );
948 assert_eq!(
949 env_value(&command, "PGSSLMODE"),
950 Some(OsStr::new("require"))
951 );
952
953 let command = restore_command(&database, dump);
954 assert_eq!(command.get_program().to_string_lossy(), "pg_restore");
955 let args = arg_strings(&command);
956 for expected in [
957 "--clean",
958 "--if-exists",
959 "--no-owner",
960 "--single-transaction",
961 "--no-password",
962 "--host",
963 "db.example.org",
964 "--dbname",
965 "plm",
966 "/backup/db.dump",
967 ] {
968 assert!(
969 args.iter().any(|arg| arg.as_str() == expected),
970 "missing {expected}: {args:?}"
971 );
972 }
973 assert!(!args.iter().any(|arg| arg.contains("pw-secret")));
974 }
975
976 #[test]
977 fn hashes_files() {
978 let dir = temp_dir("hash");
979 let file = dir.join("db.dump");
980 fs::write(&file, b"hello backup").unwrap();
981 assert_eq!(sha256_file(&file).unwrap(), sha256_bytes(b"hello backup"));
982 fs::remove_dir_all(dir).ok();
983 }
984
985 #[test]
986 fn verify_checks_manifest_dump_and_object_count() {
987 let dir = temp_dir("verify");
988 let dump = b"database dump";
989 fs::write(dir.join(DUMP_FILE), dump).unwrap();
990 let objects = dir.join(VAULT_DIR).join("objects");
991 write_object(&objects, b"file one");
992 fs::write(
993 dir.join(MANIFEST_FILE),
994 Manifest {
995 created_at_unix: 1,
996 app_version: "0.1.0".into(),
997 vault_objects: 1,
998 db_dump_sha256: sha256_bytes(dump),
999 encryption: None,
1000 }
1001 .to_json(),
1002 )
1003 .unwrap();
1004 assert_eq!(verify_backup(&dir).unwrap().vault_objects, 1);
1005
1006 fs::write(dir.join(DUMP_FILE), b"tampered").unwrap();
1007 let error = verify_backup(&dir).unwrap_err().to_string();
1008 assert!(error.contains("checksum mismatch"), "{error}");
1009 fs::remove_dir_all(dir).ok();
1010 }
1011
1012 #[test]
1013 fn encrypted_backups_verify_and_restore_only_with_the_passphrase() {
1014 let pass = "correct horse battery staple";
1015 let key = BackupKey::for_tests(pass);
1017 let source = temp_dir("enc-source");
1018 let dir = temp_dir("enc-backup");
1019 let plain_dump = source.join("db.dump");
1020 fs::write(&plain_dump, b"database dump").unwrap();
1021 crate::backupcrypt::encrypt_file(&key, &plain_dump, &dir.join(DUMP_FILE)).unwrap();
1022 let vault_objects = source.join("objects");
1023 let hash = write_object(&vault_objects, b"file one");
1024 let backup_objects = dir.join(VAULT_DIR).join("objects");
1025 let copied = copy_checked_objects(&vault_objects, &backup_objects, Some(&key)).unwrap();
1026 assert!(copied.contains(&hash));
1027 let stored = object_path_under(&backup_objects, &hash).unwrap();
1028 assert_ne!(fs::read(&stored).unwrap(), b"file one", "stored encrypted");
1029 fs::write(
1030 dir.join(MANIFEST_FILE),
1031 Manifest {
1032 created_at_unix: 1,
1033 app_version: "0.1.0".into(),
1034 vault_objects: 1,
1035 db_dump_sha256: sha256_bytes(b"database dump"),
1036 encryption: Some(key.manifest_entry()),
1037 }
1038 .to_json(),
1039 )
1040 .unwrap();
1041 assert!(is_encrypted(&dir).unwrap());
1042
1043 let (manifest, found) = verify_backup_with(&dir, Some(pass)).unwrap();
1044 assert_eq!(manifest.vault_objects, 1);
1045 assert!(found.is_some());
1046 let error = verify_backup_with(&dir, None).unwrap_err().to_string();
1047 assert!(error.contains("encrypted"), "{error}");
1048 let error = verify_backup_with(&dir, Some("a wrong passphrase"))
1049 .unwrap_err()
1050 .to_string();
1051 assert!(error.contains("wrong backup passphrase"), "{error}");
1052
1053 let target = temp_dir("enc-target");
1055 let key = found.unwrap();
1056 let stats = copy_objects_with(&backup_objects, &target, true, Some(&key)).unwrap();
1057 assert_eq!(stats.copied, 1);
1058 assert_eq!(
1059 fs::read(object_path_under(&target, &hash).unwrap()).unwrap(),
1060 b"file one"
1061 );
1062
1063 let mut bytes = fs::read(&stored).unwrap();
1065 let last = bytes.len() - 1;
1066 bytes[last] ^= 1;
1067 fs::write(&stored, bytes).unwrap();
1068 assert!(verify_backup_with(&dir, Some(pass)).is_err());
1069 for d in [source, dir, target] {
1070 fs::remove_dir_all(d).ok();
1071 }
1072 }
1073
1074 #[test]
1075 fn verify_detects_a_corrupt_object_and_a_wrong_count() {
1076 let dir = temp_dir("verify-object");
1077 let dump = b"dump";
1078 fs::write(dir.join(DUMP_FILE), dump).unwrap();
1079 let objects = dir.join(VAULT_DIR).join("objects");
1080 let hash = write_object(&objects, b"content");
1081 let manifest = Manifest {
1082 created_at_unix: 1,
1083 app_version: "0.1.0".into(),
1084 vault_objects: 1,
1085 db_dump_sha256: sha256_bytes(dump),
1086 encryption: None,
1087 };
1088 fs::write(dir.join(MANIFEST_FILE), manifest.to_json()).unwrap();
1089
1090 fs::write(object_path_under(&objects, &hash).unwrap(), b"changed").unwrap();
1091 let error = verify_backup(&dir).unwrap_err().to_string();
1092 assert!(error.contains("corrupt"), "{error}");
1093
1094 fs::write(
1095 dir.join(MANIFEST_FILE),
1096 Manifest {
1097 vault_objects: 2,
1098 ..manifest
1099 }
1100 .to_json(),
1101 )
1102 .unwrap();
1103 let error = verify_backup(&dir).unwrap_err().to_string();
1104 assert!(error.contains("manifest says 2"), "{error}");
1105 fs::remove_dir_all(dir).ok();
1106 }
1107
1108 #[test]
1109 fn object_paths_are_validated() {
1110 let root = Path::new("/vault/objects");
1111 assert!(object_path_under(root, "../../etc/passwd").is_err());
1112 assert!(object_path_under(root, &"g".repeat(64)).is_err());
1113 assert!(object_path_under(root, &"AB".repeat(32)).is_err());
1114 let hash = "ab".repeat(32);
1115 assert_eq!(
1116 object_path_under(root, &hash).unwrap(),
1117 root.join("ab").join("ab").join(&hash)
1118 );
1119
1120 let dir = temp_dir("paths");
1121 let objects = dir.join("objects");
1122 fs::create_dir_all(objects.join("ab").join("cd")).unwrap();
1123 fs::write(objects.join("ab").join("cd").join("not-a-hash"), b"x").unwrap();
1124 assert!(collect_objects(&objects).is_err());
1125 fs::remove_dir_all(dir).ok();
1126
1127 let dir = temp_dir("paths-misplaced");
1128 let objects = dir.join("objects");
1129 let hash = "ab".repeat(32);
1130 let wrong = objects.join("00").join("00");
1131 fs::create_dir_all(&wrong).unwrap();
1132 fs::write(wrong.join(&hash), b"content").unwrap();
1133 assert!(collect_objects(&objects).is_err());
1134 fs::remove_dir_all(dir).ok();
1135 }
1136
1137 #[test]
1138 fn copy_skips_existing_objects() {
1139 let source_root = temp_dir("copy-source");
1140 let target_root = temp_dir("copy-target");
1141 let source = source_root.join("objects");
1142 let target = target_root.join("objects");
1143 let hash = write_object(&source, b"blob");
1144 let stats = copy_objects(&source, &target, true).unwrap();
1145 assert_eq!(
1146 stats,
1147 CopyStats {
1148 copied: 1,
1149 skipped: 0
1150 }
1151 );
1152
1153 let target_file = object_path_under(&target, &hash).unwrap();
1154 fs::write(&target_file, b"keep me").unwrap();
1155 let stats = copy_objects(&source, &target, true).unwrap();
1156 assert_eq!(
1157 stats,
1158 CopyStats {
1159 copied: 0,
1160 skipped: 1
1161 }
1162 );
1163 assert_eq!(
1164 fs::read(&target_file).unwrap(),
1165 b"keep me",
1166 "existing objects are not overwritten"
1167 );
1168 assert_eq!(
1169 copy_objects(&source, &target, false)
1170 .unwrap_err()
1171 .to_string(),
1172 format!("backup target already contains the object '{hash}'")
1173 );
1174 fs::remove_dir_all(source_root).ok();
1175 fs::remove_dir_all(target_root).ok();
1176 }
1177
1178 #[test]
1179 fn backup_target_must_exist_empty_or_not_at_all() {
1180 let root = temp_dir("target");
1181 let target = root.join("backup");
1182 prepare_target(&target).unwrap();
1183 prepare_target(&target).unwrap();
1184 fs::write(target.join("partial.dump"), b"x").unwrap();
1185 assert!(prepare_target(&target).is_err());
1186 let file = root.join("plain-file");
1187 fs::write(&file, b"x").unwrap();
1188 assert!(prepare_target(&file).is_err());
1189 fs::remove_dir_all(root).ok();
1190 }
1191}