Skip to main content

teamcraft/
backup.rs

1//! Backup and restore of the database and the file vault.
2//!
3//! A backup directory written by [`backup`] contains:
4//!
5//! ```text
6//! db.dump                    pg_dump --format=custom
7//! vault/objects/ab/cd/<sha>  content-addressed blobs (without the preview cache)
8//! manifest.json              timestamp, version, object count and SHA-256 of db.dump
9//! ```
10//!
11//! `pg_dump` and `pg_restore` are spawned with an argument array, never
12//! through a shell. The database password is passed in the child's
13//! `PGPASSWORD` environment only; it never appears in an argument or a log
14//! message.
15
16use 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    /// SHA-256 of the plain dump (also when the file is encrypted).
43    db_dump_sha256: String,
44    /// Key derivation and cipher of an encrypted backup (`backupcrypt`);
45    /// `None` = plain files.
46    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/// Result of a successful backup.
103#[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/// Result of a successful restore.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct RestoreSummary {
113    pub vault_objects: usize,
114    pub copied: usize,
115    pub skipped: usize,
116}
117
118/// Connection parameters for the PostgreSQL client tools. The password is
119/// kept separate from the argument list.
120struct 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
147/// Value of `PGSSLMODE` that corresponds to the connection options.
148fn 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
159/// Writes a backup of the configured database and vault into `dir`.
160///
161/// Every vault object is checked against its name while it is copied, and
162/// every file content the database refers to must be in the vault: a
163/// corrupt or missing object refuses the backup. A failed backup removes
164/// what it wrote (the directory, if it created it), so nothing is left that
165/// looks like a backup; the manifest is written last and renamed into place.
166pub async fn backup(config: &Config, dir: &Path) -> Result<BackupSummary> {
167    backup_with(config, dir, None).await
168}
169
170/// Like [`backup`], with every file encrypted with a key derived from
171/// `passphrase` (see [`crate::backupcrypt`]). The plain dump exists only
172/// for the moment between `pg_dump` and its encryption, inside `dir`.
173pub 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    // Read after the dump: a content is stored in the vault before the row
227    // that names it is committed, so everything the dump refers to is listed.
228    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
266/// The file contents the database refers to (as `teamcraft vault check`).
267async 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
276/// Copies every object like [`copy_objects`] and checks its content against
277/// its name; returns the hashes copied.
278fn 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            // encrypted on the way; the hash is of the plain content read
298            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
322/// Removes what a failed backup wrote: the whole directory if the backup
323/// created it, otherwise its content (it was empty before).
324fn 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
341/// Verifies a backup directory and restores it into the configured database
342/// and vault. A database that already contains tables requires `force`.
343pub async fn restore(config: &Config, dir: &Path, force: bool) -> Result<RestoreSummary> {
344    restore_with(config, dir, force, None).await
345}
346
347/// Like [`restore`]; an encrypted backup needs its `passphrase`. The dump is
348/// decrypted to a temporary file in the vault's `tmp/` for `pg_restore` and
349/// removed afterwards; objects are decrypted straight into the vault.
350pub 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
435/// Checks that `dir` is an empty directory or does not exist (then it is
436/// created); returns whether it was created.
437fn 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
567/// Copies (with a key: decrypts) the objects of a backup into a vault.
568fn 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            // decrypted next to its place and moved in once its content is checked
601            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
678/// Whether the backup in `dir` is encrypted (from its manifest).
679pub 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
691/// SHA-256 of a backup file's plain content (decrypted on the way when the
692/// backup is encrypted; authentication failures are errors).
693fn 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
700/// Checks a backup directory completely (manifest, dump, every object) and
701/// returns its manifest and – for an encrypted backup – the key.
702fn 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        // All or nothing: a failed restore (e.g. an older backup over a
785        // database with newer tables that depend on the dropped ones) must
786        // not leave a half-replaced database behind.
787        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        // cheap parameters: the manifest records them, the restore derives the same
1016        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        // decrypted into a vault, checked against the name
1054        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        // a changed byte is caught by the authentication
1064        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}