michal/tit

Browse tree · Show commit · Download archive

Diff

22e9c51918ddd8f7d7e1fb8f

Cargo.lock

Mode 100644100644; object b45f583ccd109c7c166169c2

@@ -3524,6 +3524,16 @@
 ]
 
 [[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+]
+
+[[package]]
 name = "tempfile"
 version = "3.27.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3612,6 +3622,7 @@
  "serde_json",
  "sha2",
  "ssh-key",
+ "tar",
  "tempfile",
  "thiserror",
  "tokio",

Cargo.toml

Mode 100644100644; object f9c096c01da31b8335f1fe40

@@ -28,6 +28,7 @@
 serde_json = "1.0"
 sha2 = { version = "0.11", default-features = false }
 ssh-key = { version = "=0.7.0-rc.11", default-features = false, features = ["ed25519", "p256", "std"] }
+tar = { version = "0.4", default-features = false }
 thiserror = "2.0"
 tokio = { version = "1.53", default-features = false, features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
 tokio-stream = { version = "0.1", default-features = false }

README.md

Mode 100644100644; object 1d3dc9bf526999a2476b814e

@@ -76,6 +76,35 @@
 The history does not store recovery credentials, login challenges, signatures,
 session tokens, or SSH private keys.
 
+## Backup and restore
+
+For an offline backup, stop the server and run:
+
+```text
+tit --config /srv/tit/config.toml backup /var/backups/tit-2026-07-23.tar
+```
+
+For an online backup, leave the server active and run the same command. The CLI
+sends the request through the private control socket. The server pauses Git
+ref mutations while it makes the SQLite backup and copies the repositories,
+configuration, and SSH host key.
+
+The output file must be an absolute path outside `/srv/tit`. The command creates
+a new mode-0600 file and does not replace an existing file. The backup contains
+credentials. Store it as a secret.
+
+Restore always uses a different, empty instance directory:
+
+```text
+install -d -m 700 /srv/tit-restored
+tit restore /var/backups/tit-2026-07-23.tar /srv/tit-restored
+tit --config /srv/tit-restored/config.toml doctor
+```
+
+Restore checks the manifest, all checksums, the database, and all repositories.
+It does not activate the restored instance. To activate it, stop the old server
+and explicitly start `tit --config /srv/tit-restored/config.toml serve`.
+
 An authenticated account can create a repository with SSH:
 
 ```text
@@ -497,3 +526,17 @@
 Read the
 [process lifecycle architectural decision record](docs/adr/0028-process-lifecycle.md)
 for the readiness, shutdown, and instance-lock contracts.
+
+## Milestone 6.2 gate
+
+Install stock Git. Then, run the backup and restore gate:
+
+```text
+./scripts/check-m6-2
+```
+
+This command tests offline and online backup, owner-only archive permissions,
+the global maintenance gate, manifest checksum failures, empty restore targets,
+database validation, and restored Git object access. Read the
+[backup and restore architectural decision record](docs/adr/0029-backup-and-restore.md)
+for the archive, gate, credential, and activation contracts.

docs/adr/0029-backup-and-restore.md

Mode 100644; object 4a12f21b0c12

@@ -1,0 +1,95 @@
+# Architectural decision record 0029: backup and restore
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+An instance stores metadata and secrets in SQLite. It stores Git objects and
+refs in bare repositories. It also stores the configuration and the SSH host
+private key as files. A copy of only one storage type can contain a ref state
+that does not agree with the database state.
+
+The server must continue to operate during an online backup. A restore must not
+replace an active instance or write outside the restore target.
+
+## Decision
+
+Add `tit backup FILE`. FILE must be a clean absolute path outside the instance
+directory. The command creates a new mode-0600 tar archive and never replaces
+an existing file. The archive contains:
+
+- `manifest.json`;
+- the configuration as `config.toml`;
+- a SQLite online backup as `tit.sqlite3`;
+- `ssh_host_ed25519_key`, if it exists; and
+- the complete `repositories` directory.
+
+The JSON manifest has version 1. It records the byte count, SHA-256 checksum,
+entry type, and hexadecimal filesystem-path bytes for each file and directory.
+The path encoding preserves paths that are not UTF-8. The `tar` crate supplies
+the archive implementation and removes the need for a runtime tar command.
+
+For an offline backup, the command takes the instance lock. If the server owns
+the lock, the command sends the request through the mode-0600 control socket.
+The server takes the global maintenance gate. This gate stops repository
+creation, pull-request ref operations, receive-pack ref operations, and future
+maintenance operations for the duration of the snapshot. The server first
+makes the SQLite online backup. It then copies the repositories, configuration,
+and host key while it continues to hold the gate.
+
+Add `tit restore ARCHIVE DIRECTORY`. DIRECTORY must be an existing, empty,
+private directory. Restore first reads and validates the complete archive. It
+rejects an unknown version, an unknown entry, a duplicate entry, an unsafe
+path, an unsupported entry type, a missing entry, and a checksum mismatch. It
+then creates private directories and files without following symbolic links.
+After extraction, it runs the database checks and validates each bare
+repository, object format, ref, and reachable Git object. A validation failure
+removes all extracted content.
+
+Restore does not start a server and does not replace the source instance. The
+operator must use the restored `config.toml` in a separate `serve` command to
+activate the restored instance.
+
+## Failure and threat cases
+
+The archive contains account credentials, session hashes, feed-token hashes,
+recovery-credential hashes, SSH public keys, and the SSH host private key.
+The CLI and the manifest state that the archive contains credentials. The
+archive has owner-only permissions.
+
+An output path inside the instance can enter its own repository copy and can
+make the backup unstable. The command rejects this path. `create_new` prevents
+replacement through an existing file or symbolic link.
+
+A tar archive can contain absolute paths, parent components, symbolic links,
+hard links, devices, and entries that are not in its manifest. Restore rejects
+these items before it writes a file. Restore always targets a separate empty
+directory, so it cannot partly replace an active instance.
+
+The online backup does not hold a borrowed lock guard across an asynchronous
+wait. It moves the owned gate guard into the blocking backup job. This keeps the
+gate active until all filesystem reads finish.
+
+## Evidence
+
+Unit tests create and restore an offline backup, check owner-only permissions,
+reject an existing output, reject a nonempty target, and reject changed archive
+data. A control-socket test holds a receive-pack mutation permit and checks that
+an online backup waits.
+
+The production server test imports a repository, starts the real server,
+requests an online backup through the CLI, restores the archive through the
+CLI, reads the restored SQLite record, and uses stock Git to read the restored
+commit.
+
+## Consequences
+
+The archive is intentionally not compressed. This keeps restore simple and
+makes each manifest checksum apply to the stored content. Operators can
+compress an encrypted copy after the backup completes.
+
+Online backup pauses Git writes while it copies repositories. Large
+repositories increase this pause. The implementation favors a coherent
+snapshot over write availability.

scripts/check-m6-2

Mode 100755; object 77d6f7a0f3ec

@@ -1,0 +1,7 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --bin tit backup::tests
+cargo test --locked --release --bin tit control::tests::online_backup_waits_for_a_git_mutation
+cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh

src/backup.rs

Mode 100644; object a146d3395192

@@ -1,0 +1,923 @@
+use std::collections::{BTreeMap, HashSet};
+use std::ffi::OsString;
+use std::fs::{self, File, OpenOptions};
+use std::io::{Read, Write};
+use std::os::unix::ffi::{OsStrExt, OsStringExt};
+use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
+use std::path::{Component, Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use tar::{Archive, Builder, EntryType, Header};
+use thiserror::Error;
+
+use crate::git::repository::{GitRepository, GitRepositoryError};
+use crate::instance::{InstanceError, InstanceLock, REPOSITORY_DIRECTORY};
+use crate::maintenance::MaintenanceGate;
+use crate::store::{DATABASE_FILE, Store, StoreError};
+
+const FORMAT_VERSION: u32 = 1;
+const CONFIG_ARCHIVE_PATH: &str = "config.toml";
+const DATABASE_ARCHIVE_PATH: &str = "tit.sqlite3";
+const HOST_KEY_FILE: &str = "ssh_host_ed25519_key";
+const MANIFEST_PATH: &str = "manifest.json";
+const WARNING: &str = "This backup contains credentials.";
+const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024;
+
+#[derive(Clone)]
+pub(crate) struct OnlineBackupService {
+    instance_dir: PathBuf,
+    config_path: PathBuf,
+    maintenance: MaintenanceGate,
+}
+
+impl OnlineBackupService {
+    pub(crate) fn new(
+        instance_dir: PathBuf,
+        config_path: PathBuf,
+        maintenance: MaintenanceGate,
+    ) -> Self {
+        Self {
+            instance_dir,
+            config_path,
+            maintenance,
+        }
+    }
+
+    pub(crate) async fn create(&self, output: PathBuf) -> Result<(), BackupError> {
+        let asynchronous = self.maintenance.maintenance_async().await;
+        let instance_dir = self.instance_dir.clone();
+        let config_path = self.config_path.clone();
+        let maintenance = self.maintenance.clone();
+        tokio::task::spawn_blocking(move || {
+            let _asynchronous = asynchronous;
+            let _synchronous = maintenance.maintenance();
+            create_archive(&instance_dir, &config_path, &output)
+        })
+        .await
+        .map_err(|_| BackupError::Task)??;
+        Ok(())
+    }
+}
+
+pub(crate) fn create_offline(
+    instance_dir: &Path,
+    config_path: &Path,
+    output: &Path,
+) -> Result<(), BackupError> {
+    let _lock = InstanceLock::acquire(instance_dir)?;
+    create_archive(instance_dir, config_path, output)
+}
+
+pub(crate) fn restore(archive_path: &Path, target: &Path) -> Result<(), BackupError> {
+    validate_absolute_clean(archive_path)?;
+    validate_absolute_clean(target)?;
+    validate_empty_private_directory(target)?;
+
+    let manifest = read_manifest(archive_path)?;
+    let expected = expected_files(&manifest)?;
+    verify_archive(archive_path, &expected)?;
+    extract_archive(archive_path, target, &expected)?;
+
+    if let Err(error) = validate_restored_instance(target) {
+        let _ = remove_directory_contents(target);
+        return Err(error);
+    }
+    Ok(())
+}
+
+fn create_archive(
+    instance_dir: &Path,
+    config_path: &Path,
+    output: &Path,
+) -> Result<(), BackupError> {
+    validate_absolute_clean(instance_dir)?;
+    validate_absolute_clean(config_path)?;
+    validate_absolute_clean(output)?;
+    if output.starts_with(instance_dir) {
+        return Err(BackupError::OutputInsideInstance);
+    }
+
+    let temporary_database = instance_dir.join(format!(
+        ".tit-backup-{:032x}.sqlite3",
+        rand::random::<u128>()
+    ));
+    let temporary = TemporaryFile::create(&temporary_database)?;
+    Store::open(&instance_dir.join(DATABASE_FILE))?.backup(&temporary_database)?;
+
+    let mut files = Vec::new();
+    add_file(
+        &mut files,
+        config_path.to_owned(),
+        PathBuf::from(CONFIG_ARCHIVE_PATH),
+    )?;
+    add_file(
+        &mut files,
+        temporary_database.clone(),
+        PathBuf::from(DATABASE_ARCHIVE_PATH),
+    )?;
+    let host_key = instance_dir.join(HOST_KEY_FILE);
+    if host_key.exists() {
+        add_file(&mut files, host_key, PathBuf::from(HOST_KEY_FILE))?;
+    }
+    collect_repository_files(
+        &instance_dir.join(REPOSITORY_DIRECTORY),
+        Path::new(REPOSITORY_DIRECTORY),
+        &mut files,
+    )?;
+    files.sort_by(|left, right| {
+        left.archive_path
+            .as_os_str()
+            .as_bytes()
+            .cmp(right.archive_path.as_os_str().as_bytes())
+    });
+
+    let manifest = BackupManifest {
+        version: FORMAT_VERSION,
+        warning: WARNING.to_owned(),
+        files: files
+            .iter()
+            .map(|file| ManifestFile {
+                path_hex: encode_hex(file.archive_path.as_os_str().as_bytes()),
+                bytes: file.bytes,
+                sha256: file.sha256.clone(),
+                kind: file.kind.as_str().to_owned(),
+            })
+            .collect(),
+    };
+    let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
+
+    let mut output_file = OpenOptions::new()
+        .write(true)
+        .create_new(true)
+        .mode(0o600)
+        .open(output)
+        .map_err(|source| BackupError::Io {
+            path: output.to_owned(),
+            source,
+        })?;
+    let result = (|| -> Result<(), BackupError> {
+        {
+            let mut builder = Builder::new(&mut output_file);
+            append_bytes(
+                &mut builder,
+                Path::new(MANIFEST_PATH),
+                &manifest_bytes,
+                0o600,
+            )?;
+            for archived in &files {
+                match archived.kind {
+                    ArchiveKind::File => {
+                        let mut source = File::open(&archived.source_path).map_err(|source| {
+                            BackupError::Io {
+                                path: archived.source_path.clone(),
+                                source,
+                            }
+                        })?;
+                        append_reader(
+                            &mut builder,
+                            &archived.archive_path,
+                            &mut source,
+                            archived.bytes,
+                            archived.mode,
+                        )?;
+                    }
+                    ArchiveKind::Directory => {
+                        append_directory(&mut builder, &archived.archive_path, archived.mode)?;
+                    }
+                }
+            }
+            builder.finish()?;
+        }
+        output_file.sync_all().map_err(|source| BackupError::Io {
+            path: output.to_owned(),
+            source,
+        })
+    })();
+    drop(temporary);
+    if result.is_err() {
+        let _ = fs::remove_file(output);
+    }
+    result
+}
+
+fn add_file(
+    files: &mut Vec<ArchivedFile>,
+    source_path: PathBuf,
+    archive_path: PathBuf,
+) -> Result<(), BackupError> {
+    validate_archive_path(&archive_path)?;
+    let metadata = fs::symlink_metadata(&source_path).map_err(|source| BackupError::Io {
+        path: source_path.clone(),
+        source,
+    })?;
+    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
+        return Err(BackupError::UnsafeSource(source_path));
+    }
+    let (bytes, sha256) = hash_file(&source_path)?;
+    files.push(ArchivedFile {
+        source_path,
+        archive_path,
+        bytes,
+        sha256,
+        mode: metadata.permissions().mode() & 0o700,
+        kind: ArchiveKind::File,
+    });
+    Ok(())
+}
+
+fn collect_repository_files(
+    source: &Path,
+    archive_path: &Path,
+    files: &mut Vec<ArchivedFile>,
+) -> Result<(), BackupError> {
+    let metadata = fs::symlink_metadata(source).map_err(|source_error| BackupError::Io {
+        path: source.to_owned(),
+        source: source_error,
+    })?;
+    if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
+        return Err(BackupError::UnsafeSource(source.to_owned()));
+    }
+    files.push(ArchivedFile {
+        source_path: source.to_owned(),
+        archive_path: archive_path.to_owned(),
+        bytes: 0,
+        sha256: encode_hex(Sha256::digest([])),
+        mode: metadata.permissions().mode() & 0o700,
+        kind: ArchiveKind::Directory,
+    });
+    let mut entries = fs::read_dir(source)
+        .map_err(|source_error| BackupError::Io {
+            path: source.to_owned(),
+            source: source_error,
+        })?
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(|source_error| BackupError::Io {
+            path: source.to_owned(),
+            source: source_error,
+        })?;
+    entries.sort_by(|left, right| {
+        left.file_name()
+            .as_bytes()
+            .cmp(right.file_name().as_bytes())
+    });
+    for entry in entries {
+        let child_source = entry.path();
+        let child_archive = archive_path.join(entry.file_name());
+        let child_metadata =
+            fs::symlink_metadata(&child_source).map_err(|source_error| BackupError::Io {
+                path: child_source.clone(),
+                source: source_error,
+            })?;
+        if child_metadata.file_type().is_dir() {
+            collect_repository_files(&child_source, &child_archive, files)?;
+        } else if child_metadata.file_type().is_file() {
+            add_file(files, child_source, child_archive)?;
+        } else {
+            return Err(BackupError::UnsafeSource(child_source));
+        }
+    }
+    Ok(())
+}
+
+fn hash_file(path: &Path) -> Result<(u64, String), BackupError> {
+    let mut file = File::open(path).map_err(|source| BackupError::Io {
+        path: path.to_owned(),
+        source,
+    })?;
+    let mut digest = Sha256::new();
+    let mut bytes = 0_u64;
+    let mut buffer = [0_u8; 64 * 1024];
+    loop {
+        let count = file.read(&mut buffer).map_err(|source| BackupError::Io {
+            path: path.to_owned(),
+            source,
+        })?;
+        if count == 0 {
+            break;
+        }
+        bytes = bytes
+            .checked_add(count as u64)
+            .ok_or(BackupError::SizeOverflow)?;
+        digest.update(&buffer[..count]);
+    }
+    Ok((bytes, encode_hex(digest.finalize())))
+}
+
+fn append_bytes(
+    builder: &mut Builder<&mut File>,
+    path: &Path,
+    bytes: &[u8],
+    mode: u32,
+) -> Result<(), BackupError> {
+    let mut reader = bytes;
+    append_reader(builder, path, &mut reader, bytes.len() as u64, mode)
+}
+
+fn append_reader(
+    builder: &mut Builder<&mut File>,
+    path: &Path,
+    reader: &mut impl Read,
+    bytes: u64,
+    mode: u32,
+) -> Result<(), BackupError> {
+    let mut header = Header::new_gnu();
+    header.set_entry_type(EntryType::Regular);
+    header.set_size(bytes);
+    header.set_mode(mode);
+    header.set_uid(0);
+    header.set_gid(0);
+    header.set_mtime(0);
+    header.set_cksum();
+    builder.append_data(&mut header, path, reader)?;
+    Ok(())
+}
+
+fn append_directory(
+    builder: &mut Builder<&mut File>,
+    path: &Path,
+    mode: u32,
+) -> Result<(), BackupError> {
+    let mut header = Header::new_gnu();
+    header.set_entry_type(EntryType::Directory);
+    header.set_size(0);
+    header.set_mode(mode);
+    header.set_uid(0);
+    header.set_gid(0);
+    header.set_mtime(0);
+    header.set_cksum();
+    builder.append_data(&mut header, path, std::io::empty())?;
+    Ok(())
+}
+
+fn read_manifest(archive_path: &Path) -> Result<BackupManifest, BackupError> {
+    let file = File::open(archive_path).map_err(|source| BackupError::Io {
+        path: archive_path.to_owned(),
+        source,
+    })?;
+    let mut archive = Archive::new(file);
+    let mut entries = archive.entries()?;
+    let mut entry = entries.next().ok_or(BackupError::MissingManifest)??;
+    if entry.path_bytes().as_ref() != MANIFEST_PATH.as_bytes()
+        || entry.header().entry_type() != EntryType::Regular
+        || entry.size() > MAX_MANIFEST_BYTES
+    {
+        return Err(BackupError::MissingManifest);
+    }
+    let mut contents = Vec::with_capacity(entry.size() as usize);
+    entry.read_to_end(&mut contents)?;
+    let manifest: BackupManifest = serde_json::from_slice(&contents)?;
+    if manifest.version != FORMAT_VERSION || manifest.warning != WARNING {
+        return Err(BackupError::InvalidManifest);
+    }
+    Ok(manifest)
+}
+
+fn expected_files(
+    manifest: &BackupManifest,
+) -> Result<BTreeMap<Vec<u8>, ManifestFile>, BackupError> {
+    let mut expected = BTreeMap::new();
+    for file in &manifest.files {
+        let bytes = decode_hex(&file.path_hex)?;
+        let path = PathBuf::from(OsString::from_vec(bytes.clone()));
+        validate_archive_path(&path)?;
+        if decode_hex(&file.sha256)?.len() != 32
+            || !matches!(file.kind.as_str(), "file" | "directory")
+            || (file.kind == "directory"
+                && (file.bytes != 0 || file.sha256 != encode_hex(Sha256::digest([]))))
+            || expected.insert(bytes, file.clone()).is_some()
+        {
+            return Err(BackupError::InvalidManifest);
+        }
+    }
+    for required in [
+        CONFIG_ARCHIVE_PATH.as_bytes(),
+        DATABASE_ARCHIVE_PATH.as_bytes(),
+    ] {
+        if !expected.contains_key(required) {
+            return Err(BackupError::InvalidManifest);
+        }
+    }
+    Ok(expected)
+}
+
+fn verify_archive(
+    archive_path: &Path,
+    expected: &BTreeMap<Vec<u8>, ManifestFile>,
+) -> Result<(), BackupError> {
+    let file = File::open(archive_path).map_err(|source| BackupError::Io {
+        path: archive_path.to_owned(),
+        source,
+    })?;
+    let mut archive = Archive::new(file);
+    let mut seen = HashSet::new();
+    for (index, entry) in archive.entries()?.enumerate() {
+        let mut entry = entry?;
+        let path = entry.path_bytes().into_owned();
+        if index == 0 && path == MANIFEST_PATH.as_bytes() {
+            continue;
+        }
+        let entry_kind = if entry.header().entry_type() == EntryType::Regular {
+            "file"
+        } else if entry.header().entry_type() == EntryType::Directory {
+            "directory"
+        } else {
+            return Err(BackupError::UnsafeArchivePath(PathBuf::from(
+                OsString::from_vec(path),
+            )));
+        };
+        let Some(file) = expected.get(path.as_slice()) else {
+            return Err(BackupError::UnexpectedArchiveEntry(PathBuf::from(
+                OsString::from_vec(path),
+            )));
+        };
+        if !seen.insert(path.clone()) || entry.size() != file.bytes || entry_kind != file.kind {
+            return Err(BackupError::Checksum(PathBuf::from(OsString::from_vec(
+                path,
+            ))));
+        }
+        let mut digest = Sha256::new();
+        let copied = std::io::copy(&mut entry, &mut DigestWriter(&mut digest))?;
+        if copied != file.bytes || encode_hex(digest.finalize()) != file.sha256 {
+            return Err(BackupError::Checksum(PathBuf::from(OsString::from_vec(
+                path,
+            ))));
+        }
+    }
+    if seen.len() != expected.len() {
+        return Err(BackupError::MissingArchiveEntry);
+    }
+    Ok(())
+}
+
+fn extract_archive(
+    archive_path: &Path,
+    target: &Path,
+    expected: &BTreeMap<Vec<u8>, ManifestFile>,
+) -> Result<(), BackupError> {
+    let result = (|| -> Result<(), BackupError> {
+        let file = File::open(archive_path).map_err(|source| BackupError::Io {
+            path: archive_path.to_owned(),
+            source,
+        })?;
+        let mut archive = Archive::new(file);
+        let mut seen = HashSet::new();
+        for (index, entry) in archive.entries()?.enumerate() {
+            let mut entry = entry?;
+            let path_bytes = entry.path_bytes().into_owned();
+            if index == 0 && path_bytes == MANIFEST_PATH.as_bytes() {
+                continue;
+            }
+            let manifest_file = expected
+                .get(path_bytes.as_slice())
+                .ok_or(BackupError::InvalidManifest)?;
+            let relative = PathBuf::from(OsString::from_vec(path_bytes));
+            let expected_type = if manifest_file.kind == "directory" {
+                EntryType::Directory
+            } else {
+                EntryType::Regular
+            };
+            if !seen.insert(relative.clone())
+                || entry.header().entry_type() != expected_type
+                || entry.size() != manifest_file.bytes
+            {
+                return Err(BackupError::Checksum(relative));
+            }
+            let destination = target.join(&relative);
+            create_private_parents(target, &relative)?;
+            if manifest_file.kind == "directory" {
+                match fs::create_dir(&destination) {
+                    Ok(()) => {}
+                    Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
+                    Err(source) => {
+                        return Err(BackupError::Io {
+                            path: destination,
+                            source,
+                        });
+                    }
+                }
+                fs::set_permissions(&destination, fs::Permissions::from_mode(0o700)).map_err(
+                    |source| BackupError::Io {
+                        path: destination,
+                        source,
+                    },
+                )?;
+            } else {
+                let mut output = OpenOptions::new()
+                    .write(true)
+                    .create_new(true)
+                    .mode(0o600)
+                    .open(&destination)
+                    .map_err(|source| BackupError::Io {
+                        path: destination.clone(),
+                        source,
+                    })?;
+                std::io::copy(&mut entry, &mut output)?;
+                output.sync_all().map_err(|source| BackupError::Io {
+                    path: destination.clone(),
+                    source,
+                })?;
+                let (bytes, sha256) = hash_file(&destination)?;
+                if bytes != manifest_file.bytes || sha256 != manifest_file.sha256 {
+                    return Err(BackupError::Checksum(relative));
+                }
+            }
+        }
+        if seen.len() != expected.len() {
+            return Err(BackupError::MissingArchiveEntry);
+        }
+        Ok(())
+    })();
+    if result.is_err() {
+        let _ = remove_directory_contents(target);
+    }
+    result
+}
+
+fn create_private_parents(target: &Path, relative: &Path) -> Result<(), BackupError> {
+    let Some(parent) = relative.parent() else {
+        return Ok(());
+    };
+    let mut current = target.to_owned();
+    for component in parent.components() {
+        let Component::Normal(name) = component else {
+            return Err(BackupError::UnsafeArchivePath(relative.to_owned()));
+        };
+        current.push(name);
+        match fs::create_dir(&current) {
+            Ok(()) => fs::set_permissions(&current, fs::Permissions::from_mode(0o700)).map_err(
+                |source| BackupError::Io {
+                    path: current.clone(),
+                    source,
+                },
+            )?,
+            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
+            Err(source) => {
+                return Err(BackupError::Io {
+                    path: current,
+                    source,
+                });
+            }
+        }
+    }
+    Ok(())
+}
+
+fn validate_restored_instance(target: &Path) -> Result<(), BackupError> {
+    crate::store::doctor(target)?;
+    let store = Store::open(&target.join(DATABASE_FILE))?;
+    for repository in store.all_repositories()? {
+        let path = target
+            .join(REPOSITORY_DIRECTORY)
+            .join(format!("{}.git", repository.id));
+        let git = GitRepository::open(&path)?;
+        let expected = match repository.object_format.as_str() {
+            "sha1" => gix::hash::Kind::Sha1,
+            "sha256" => gix::hash::Kind::Sha256,
+            _ => return Err(BackupError::RepositoryFormat(repository.object_format)),
+        };
+        if git.object_format() != expected {
+            return Err(BackupError::RepositoryFormat(repository.id));
+        }
+        git.integrity_check()?;
+    }
+    Ok(())
+}
+
+fn validate_empty_private_directory(path: &Path) -> Result<(), BackupError> {
+    let metadata = fs::symlink_metadata(path).map_err(|source| BackupError::Io {
+        path: path.to_owned(),
+        source,
+    })?;
+    if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
+        return Err(BackupError::UnsafeTarget(path.to_owned()));
+    }
+    let mode = metadata.permissions().mode() & 0o777;
+    if mode & 0o077 != 0 {
+        return Err(BackupError::TargetPermissions { mode });
+    }
+    if fs::read_dir(path)
+        .map_err(|source| BackupError::Io {
+            path: path.to_owned(),
+            source,
+        })?
+        .next()
+        .is_some()
+    {
+        return Err(BackupError::TargetNotEmpty);
+    }
+    Ok(())
+}
+
+fn validate_absolute_clean(path: &Path) -> Result<(), BackupError> {
+    if !path.is_absolute()
+        || path
+            .components()
+            .any(|part| matches!(part, Component::CurDir | Component::ParentDir))
+    {
+        return Err(BackupError::UncleanPath(path.to_owned()));
+    }
+    Ok(())
+}
+
+fn validate_archive_path(path: &Path) -> Result<(), BackupError> {
+    if path.as_os_str().is_empty()
+        || path.is_absolute()
+        || path.components().any(|part| {
+            !matches!(part, Component::Normal(_))
+                || matches!(part, Component::Normal(name) if name.as_bytes().is_empty())
+        })
+    {
+        return Err(BackupError::UnsafeArchivePath(path.to_owned()));
+    }
+    Ok(())
+}
+
+fn remove_directory_contents(path: &Path) -> std::io::Result<()> {
+    for entry in fs::read_dir(path)? {
+        let entry = entry?;
+        let metadata = fs::symlink_metadata(entry.path())?;
+        if metadata.file_type().is_dir() {
+            fs::remove_dir_all(entry.path())?;
+        } else {
+            fs::remove_file(entry.path())?;
+        }
+    }
+    Ok(())
+}
+
+fn encode_hex(bytes: impl AsRef<[u8]>) -> String {
+    const HEX: &[u8; 16] = b"0123456789abcdef";
+    let bytes = bytes.as_ref();
+    let mut encoded = String::with_capacity(bytes.len() * 2);
+    for byte in bytes {
+        encoded.push(char::from(HEX[(byte >> 4) as usize]));
+        encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
+    }
+    encoded
+}
+
+fn decode_hex(encoded: &str) -> Result<Vec<u8>, BackupError> {
+    if !encoded.len().is_multiple_of(2) {
+        return Err(BackupError::InvalidManifest);
+    }
+    encoded
+        .as_bytes()
+        .chunks_exact(2)
+        .map(|pair| {
+            let high = decode_nibble(pair[0])?;
+            let low = decode_nibble(pair[1])?;
+            Ok((high << 4) | low)
+        })
+        .collect()
+}
+
+fn decode_nibble(byte: u8) -> Result<u8, BackupError> {
+    match byte {
+        b'0'..=b'9' => Ok(byte - b'0'),
+        b'a'..=b'f' => Ok(byte - b'a' + 10),
+        _ => Err(BackupError::InvalidManifest),
+    }
+}
+
+struct DigestWriter<'a>(&'a mut Sha256);
+
+impl Write for DigestWriter<'_> {
+    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
+        self.0.update(buffer);
+        Ok(buffer.len())
+    }
+
+    fn flush(&mut self) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+struct ArchivedFile {
+    source_path: PathBuf,
+    archive_path: PathBuf,
+    bytes: u64,
+    sha256: String,
+    mode: u32,
+    kind: ArchiveKind,
+}
+
+#[derive(Clone, Copy)]
+enum ArchiveKind {
+    File,
+    Directory,
+}
+
+impl ArchiveKind {
+    fn as_str(self) -> &'static str {
+        match self {
+            Self::File => "file",
+            Self::Directory => "directory",
+        }
+    }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct BackupManifest {
+    version: u32,
+    warning: String,
+    files: Vec<ManifestFile>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct ManifestFile {
+    path_hex: String,
+    bytes: u64,
+    sha256: String,
+    kind: String,
+}
+
+struct TemporaryFile {
+    path: PathBuf,
+}
+
+impl TemporaryFile {
+    fn create(path: &Path) -> Result<Self, BackupError> {
+        OpenOptions::new()
+            .write(true)
+            .create_new(true)
+            .mode(0o600)
+            .open(path)
+            .map_err(|source| BackupError::Io {
+                path: path.to_owned(),
+                source,
+            })?;
+        Ok(Self {
+            path: path.to_owned(),
+        })
+    }
+}
+
+impl Drop for TemporaryFile {
+    fn drop(&mut self) {
+        let _ = fs::remove_file(&self.path);
+    }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum BackupError {
+    #[error("backup path is not a clean absolute path: {0}")]
+    UncleanPath(PathBuf),
+    #[error("the backup output must be outside the instance directory")]
+    OutputInsideInstance,
+    #[error("backup source path is unsafe: {0}")]
+    UnsafeSource(PathBuf),
+    #[error("backup archive path is unsafe: {0}")]
+    UnsafeArchivePath(PathBuf),
+    #[error("backup archive has an unexpected file: {0}")]
+    UnexpectedArchiveEntry(PathBuf),
+    #[error("backup archive does not have a manifest")]
+    MissingManifest,
+    #[error("backup manifest is invalid")]
+    InvalidManifest,
+    #[error("backup archive does not have all manifest files")]
+    MissingArchiveEntry,
+    #[error("backup checksum does not match for: {0}")]
+    Checksum(PathBuf),
+    #[error("restore target is unsafe: {0}")]
+    UnsafeTarget(PathBuf),
+    #[error("restore target permissions are {mode:o}, expected 700 or more restrictive")]
+    TargetPermissions { mode: u32 },
+    #[error("restore target is not empty")]
+    TargetNotEmpty,
+    #[error("repository object format is invalid: {0}")]
+    RepositoryFormat(String),
+    #[error("backup size is too large")]
+    SizeOverflow,
+    #[error("backup task failed")]
+    Task,
+    #[error("backup I/O failed for {path}: {source}")]
+    Io {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error(transparent)]
+    Archive(#[from] std::io::Error),
+    #[error(transparent)]
+    Manifest(#[from] serde_json::Error),
+    #[error(transparent)]
+    Instance(#[from] InstanceError),
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error(transparent)]
+    Git(#[from] GitRepositoryError),
+}
+
+#[cfg(test)]
+mod tests {
+    use std::os::unix::fs::PermissionsExt;
+
+    use tempfile::TempDir;
+
+    use super::*;
+
+    fn private_directory() -> TempDir {
+        let directory = TempDir::new().expect("create a temporary directory");
+        fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700))
+            .expect("make the directory private");
+        directory
+    }
+
+    fn source_instance() -> (TempDir, PathBuf) {
+        let instance = private_directory();
+        let config = instance.path().join(CONFIG_ARCHIVE_PATH);
+        let mut file = OpenOptions::new()
+            .write(true)
+            .create_new(true)
+            .mode(0o600)
+            .open(&config)
+            .expect("create the configuration");
+        file.write_all(
+            b"version = 1\npublic_url = \"http://localhost:3000/\"\n\n[http]\nlisten = \"127.0.0.1:3000\"\n",
+        )
+        .expect("write the configuration");
+        Store::open(&instance.path().join(DATABASE_FILE)).expect("create the database");
+        fs::create_dir(instance.path().join(REPOSITORY_DIRECTORY))
+            .expect("create the repository directory");
+        fs::set_permissions(
+            instance.path().join(REPOSITORY_DIRECTORY),
+            fs::Permissions::from_mode(0o700),
+        )
+        .expect("make the repository directory private");
+        (instance, config)
+    }
+
+    #[test]
+    fn creates_and_restores_a_private_offline_backup() {
+        let (instance, config) = source_instance();
+        let output_directory = private_directory();
+        let archive = output_directory.path().join("instance.tar");
+        create_offline(instance.path(), &config, &archive).expect("create a backup");
+        assert_eq!(
+            fs::metadata(&archive)
+                .expect("inspect the backup")
+                .permissions()
+                .mode()
+                & 0o777,
+            0o600
+        );
+
+        let target = private_directory();
+        restore(&archive, target.path()).expect("restore the backup");
+        assert_eq!(
+            fs::read(target.path().join(CONFIG_ARCHIVE_PATH))
+                .expect("read the restored configuration"),
+            fs::read(config).expect("read the source configuration")
+        );
+        crate::store::doctor(target.path()).expect("check the restored database");
+    }
+
+    #[test]
+    fn refuses_a_nonempty_target_and_a_second_archive() {
+        let (instance, config) = source_instance();
+        let output_directory = private_directory();
+        let archive = output_directory.path().join("instance.tar");
+        create_offline(instance.path(), &config, &archive).expect("create a backup");
+        assert!(matches!(
+            create_offline(instance.path(), &config, &archive),
+            Err(BackupError::Io { .. })
+        ));
+
+        let target = private_directory();
+        fs::write(target.path().join("keep"), b"keep").expect("make the target nonempty");
+        assert!(matches!(
+            restore(&archive, target.path()),
+            Err(BackupError::TargetNotEmpty)
+        ));
+        assert_eq!(
+            fs::read(target.path().join("keep")).expect("read the existing file"),
+            b"keep"
+        );
+    }
+
+    #[test]
+    fn rejects_archive_data_that_does_not_match_the_manifest() {
+        let (instance, config) = source_instance();
+        let output_directory = private_directory();
+        let archive = output_directory.path().join("instance.tar");
+        create_offline(instance.path(), &config, &archive).expect("create a backup");
+        let mut bytes = fs::read(&archive).expect("read the backup");
+        let position = bytes
+            .windows(b"version = 1".len())
+            .position(|candidate| candidate == b"version = 1")
+            .expect("find configuration data");
+        bytes[position] ^= 1;
+        fs::write(&archive, bytes).expect("damage the backup");
+
+        let target = private_directory();
+        assert!(restore(&archive, target.path()).is_err());
+        assert!(
+            fs::read_dir(target.path())
+                .expect("inspect the restore target")
+                .next()
+                .is_none()
+        );
+    }
+}

src/cli.rs

Mode 100644100644; object d07e501a78ef6077cfd94df9

@@ -52,6 +52,18 @@
     InviteCode,
     /// Check the instance database
     Doctor,
+    /// Create a backup archive
+    Backup {
+        /// Write the backup archive to FILE
+        output: PathBuf,
+    },
+    /// Restore a backup archive to an empty instance directory
+    Restore {
+        /// Read the backup archive from FILE
+        archive: PathBuf,
+        /// Restore the instance into DIRECTORY
+        target: PathBuf,
+    },
     /// Set up an uninitialized instance
     Setup {
         #[command(subcommand)]

src/config.rs

Mode 100644100644; object 855737c283761bdbb2c0b55c

@@ -15,6 +15,7 @@
 
 #[derive(Debug)]
 pub(crate) struct Config {
+    pub(crate) config_path: PathBuf,
     pub(crate) public_url: Url,
     pub(crate) instance_dir: PathBuf,
     pub(crate) http_listen: SocketAddr,
@@ -291,6 +292,7 @@
     let ssh_public_port = cli.ssh_public_port.unwrap_or(file.ssh.public_port);
 
     let config = Config {
+        config_path: path.clone(),
         public_url,
         instance_dir,
         http_listen,

src/control.rs

Mode 100644100644; object 23a2bef622ba583aac731c2a

@@ -1,4 +1,6 @@
+use std::ffi::OsString;
 use std::fs;
+use std::os::unix::ffi::{OsStrExt, OsStringExt};
 use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
 use std::path::{Path, PathBuf};
 use std::time::Duration;
@@ -10,11 +12,15 @@
 use tokio::task::{JoinHandle, JoinSet};
 
 use crate::account::{AccountError, AccountService};
+use crate::backup::OnlineBackupService;
 
 pub(crate) const CONTROL_SOCKET_FILE: &str = "control.sock";
 const REQUEST: &[u8] = b"invite-code\n";
+const BACKUP_REQUEST_PREFIX: &[u8] = b"backup ";
+const MAX_REQUEST_BYTES: usize = 16 * 1024;
 const MAX_RESPONSE_BYTES: usize = 256;
 const IO_TIMEOUT: Duration = Duration::from_secs(5);
+const BACKUP_TIMEOUT: Duration = Duration::from_secs(10 * 60);
 
 pub(crate) struct RunningControlServer {
     shutdown: oneshot::Sender<()>,
@@ -22,9 +28,29 @@
 }
 
 impl RunningControlServer {
+    #[allow(
+        dead_code,
+        reason = "the production server uses the backup-enabled constructor"
+    )]
     pub(crate) fn start(
         instance_dir: &Path,
         accounts: AccountService,
+    ) -> Result<Self, ControlError> {
+        Self::start_inner(instance_dir, accounts, None)
+    }
+
+    pub(crate) fn start_with_backup(
+        instance_dir: &Path,
+        accounts: AccountService,
+        backup: OnlineBackupService,
+    ) -> Result<Self, ControlError> {
+        Self::start_inner(instance_dir, accounts, Some(backup))
+    }
+
+    fn start_inner(
+        instance_dir: &Path,
+        accounts: AccountService,
+        backup: Option<OnlineBackupService>,
     ) -> Result<Self, ControlError> {
         let path = instance_dir.join(CONTROL_SOCKET_FILE);
         refuse_existing_path(&path)?;
@@ -73,8 +99,9 @@
                     accepted = listener.accept() => {
                         let (stream, _) = accepted.map_err(ControlError::Accept)?;
                         let service = accounts.clone();
+                        let backup = backup.clone();
                         connections.spawn(async move {
-                            let _ = handle(stream, service).await;
+                            let _ = handle(stream, service, backup).await;
                         });
                     },
                     _ = connections.join_next(), if !connections.is_empty() => {}
@@ -109,6 +136,32 @@
 }
 
 pub(crate) async fn request_invitation(instance_dir: &Path) -> Result<String, ControlError> {
+    let response = request(instance_dir, REQUEST, IO_TIMEOUT).await?;
+    response
+        .strip_prefix("ok ")
+        .map(str::to_owned)
+        .ok_or(ControlError::InvalidResponse)
+}
+
+pub(crate) async fn request_backup(instance_dir: &Path, output: &Path) -> Result<(), ControlError> {
+    let mut request_bytes = BACKUP_REQUEST_PREFIX.to_vec();
+    request_bytes.extend_from_slice(encode_hex(output.as_os_str().as_bytes()).as_bytes());
+    request_bytes.push(b'\n');
+    let response = request(instance_dir, &request_bytes, BACKUP_TIMEOUT).await?;
+    if response == "ok" {
+        Ok(())
+    } else if let Some(message) = response.strip_prefix("error ") {
+        Err(ControlError::Remote(message.to_owned()))
+    } else {
+        Err(ControlError::InvalidResponse)
+    }
+}
+
+async fn request(
+    instance_dir: &Path,
+    request: &[u8],
+    timeout: Duration,
+) -> Result<String, ControlError> {
     let path = instance_dir.join(CONTROL_SOCKET_FILE);
     let metadata = fs::symlink_metadata(&path).map_err(|source| ControlError::Io {
         path: path.clone(),
@@ -122,7 +175,7 @@
     }
     let operation = async {
         let mut stream = UnixStream::connect(&path).await?;
-        stream.write_all(REQUEST).await?;
+        stream.write_all(request).await?;
         stream.shutdown().await?;
         let mut response = Vec::new();
         stream
@@ -134,38 +187,92 @@
         }
         let response = String::from_utf8(response).map_err(|_| ControlError::InvalidResponse)?;
         response
-            .strip_prefix("ok ")
-            .and_then(|value| value.strip_suffix('\n'))
+            .strip_suffix('\n')
             .map(str::to_owned)
             .ok_or(ControlError::InvalidResponse)
     };
-    tokio::time::timeout(IO_TIMEOUT, operation)
+    tokio::time::timeout(timeout, operation)
         .await
         .map_err(|_| ControlError::Timeout)?
 }
 
-async fn handle(mut stream: UnixStream, accounts: AccountService) -> Result<(), ControlError> {
+async fn handle(
+    mut stream: UnixStream,
+    accounts: AccountService,
+    backup: Option<OnlineBackupService>,
+) -> Result<(), ControlError> {
     let mut request = Vec::new();
     tokio::time::timeout(
         IO_TIMEOUT,
         (&mut stream)
-            .take((REQUEST.len() + 1) as u64)
+            .take((MAX_REQUEST_BYTES + 1) as u64)
             .read_to_end(&mut request),
     )
     .await
     .map_err(|_| ControlError::Timeout)??;
-    if request != REQUEST {
+    if request.len() > MAX_REQUEST_BYTES {
         stream.write_all(b"error invalid-request\n").await?;
         return Ok(());
     }
-    let invitation = tokio::task::spawn_blocking(move || accounts.issue_invitation())
-        .await
-        .map_err(|_| ControlError::Join)??;
-    stream
-        .write_all(format!("ok {invitation}\n").as_bytes())
-        .await?;
+    if request == REQUEST {
+        let invitation = tokio::task::spawn_blocking(move || accounts.issue_invitation())
+            .await
+            .map_err(|_| ControlError::Join)??;
+        stream
+            .write_all(format!("ok {invitation}\n").as_bytes())
+            .await?;
+    } else if let Some(encoded) = request
+        .strip_prefix(BACKUP_REQUEST_PREFIX)
+        .and_then(|value| value.strip_suffix(b"\n"))
+    {
+        let Some(backup) = backup else {
+            stream.write_all(b"error backup-unavailable\n").await?;
+            return Ok(());
+        };
+        let output = match decode_hex(encoded) {
+            Some(path) => PathBuf::from(OsString::from_vec(path)),
+            None => {
+                stream.write_all(b"error invalid-request\n").await?;
+                return Ok(());
+            }
+        };
+        match backup.create(output).await {
+            Ok(()) => stream.write_all(b"ok\n").await?,
+            Err(_) => stream.write_all(b"error backup-failed\n").await?,
+        }
+    } else {
+        stream.write_all(b"error invalid-request\n").await?;
+    }
     stream.shutdown().await?;
     Ok(())
+}
+
+fn encode_hex(bytes: &[u8]) -> String {
+    const HEX: &[u8; 16] = b"0123456789abcdef";
+    let mut encoded = String::with_capacity(bytes.len() * 2);
+    for byte in bytes {
+        encoded.push(char::from(HEX[(byte >> 4) as usize]));
+        encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
+    }
+    encoded
+}
+
+fn decode_hex(encoded: &[u8]) -> Option<Vec<u8>> {
+    if !encoded.len().is_multiple_of(2) {
+        return None;
+    }
+    encoded
+        .chunks_exact(2)
+        .map(|pair| Some((decode_nibble(pair[0])? << 4) | decode_nibble(pair[1])?))
+        .collect()
+}
+
+fn decode_nibble(byte: u8) -> Option<u8> {
+    match byte {
+        b'0'..=b'9' => Some(byte - b'0'),
+        b'a'..=b'f' => Some(byte - b'a' + 10),
+        _ => None,
+    }
 }
 
 fn refuse_existing_path(path: &Path) -> Result<(), ControlError> {
@@ -225,6 +332,8 @@
     Timeout,
     #[error("control response is invalid")]
     InvalidResponse,
+    #[error("control request failed: {0}")]
+    Remote(String),
     #[error("control task failed")]
     Join,
     #[error(transparent)]
@@ -233,10 +342,12 @@
 
 #[cfg(test)]
 mod tests {
-    use std::os::unix::fs::symlink;
+    use std::io::Write;
+    use std::os::unix::fs::{OpenOptionsExt, symlink};
 
     use tempfile::TempDir;
 
+    use crate::maintenance::MaintenanceGate;
     use crate::store::Store;
 
     use super::*;
@@ -294,5 +405,58 @@
             ),
             Err(ControlError::UnsafePath(candidate)) if candidate == path
         ));
+    }
+
+    #[tokio::test]
+    async fn online_backup_waits_for_a_git_mutation() {
+        let directory = TempDir::new().expect("create an instance directory");
+        let config = directory.path().join("config.toml");
+        let mut config_file = fs::OpenOptions::new()
+            .write(true)
+            .create_new(true)
+            .mode(0o600)
+            .open(&config)
+            .expect("create the configuration");
+        config_file
+            .write_all(b"version = 1\npublic_url = \"http://localhost:3000/\"\n")
+            .expect("write the configuration");
+        let database = directory.path().join(crate::store::DATABASE_FILE);
+        Store::open(&database).expect("create the database");
+        fs::create_dir(directory.path().join(crate::instance::REPOSITORY_DIRECTORY))
+            .expect("create the repository directory");
+
+        let gate = MaintenanceGate::default();
+        let mutation = gate.mutation_async().await;
+        let backup_directory = TempDir::new().expect("create a backup directory");
+        let output = backup_directory.path().join("instance.tar");
+        let service = OnlineBackupService::new(directory.path().to_owned(), config, gate.clone());
+        let server = RunningControlServer::start_with_backup(
+            directory.path(),
+            AccountService::new(database),
+            service,
+        )
+        .expect("start the control server");
+
+        let request = tokio::spawn({
+            let instance = directory.path().to_owned();
+            let output = output.clone();
+            async move { request_backup(&instance, &output).await }
+        });
+        tokio::time::sleep(Duration::from_millis(50)).await;
+        assert!(!output.exists());
+        drop(mutation);
+        request
+            .await
+            .expect("join the backup request")
+            .expect("create the online backup");
+        assert_eq!(
+            fs::metadata(&output)
+                .expect("inspect the backup")
+                .permissions()
+                .mode()
+                & 0o777,
+            0o600
+        );
+        server.shutdown().await.expect("stop the control server");
     }
 }

src/git/repository.rs

Mode 100644100644; object adb8b5fff19600eea6aeefaf

@@ -297,6 +297,17 @@
         Ok(pack)
     }
 
+    pub(crate) fn integrity_check(&self) -> Result<(), GitRepositoryError> {
+        let roots: Vec<_> = self
+            .references()?
+            .into_iter()
+            .flat_map(|reference| [Some(reference.target), reference.peeled])
+            .flatten()
+            .collect();
+        self.walk_reachable(&roots, false)?;
+        Ok(())
+    }
+
     fn walk_reachable(
         &self,
         roots: &[ObjectId],

src/git/transport.rs

Mode 100644100644; object 76f3d3c757fad6e63d355631

@@ -6,6 +6,7 @@
 use thiserror::Error;
 use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore};
 
+use crate::maintenance::MaintenanceGate;
 use crate::policy::{RepositoryOperation, RepositoryPolicy};
 
 const MAX_BLOCKING_GIT_JOBS: usize = 4;
@@ -18,6 +19,7 @@
     push_database: Option<PathBuf>,
     push_jobs: std::sync::Arc<Semaphore>,
     blocking_jobs: std::sync::Arc<Semaphore>,
+    maintenance: MaintenanceGate,
 }
 
 impl GitRepositories {
@@ -33,6 +35,7 @@
             push_database: None,
             push_jobs: std::sync::Arc::new(Semaphore::new(1)),
             blocking_jobs: std::sync::Arc::new(Semaphore::new(MAX_BLOCKING_GIT_JOBS)),
+            maintenance: MaintenanceGate::default(),
         })
     }
 
@@ -68,9 +71,18 @@
         root: &Path,
         database: &Path,
     ) -> Result<Self, RepositoryPathError> {
+        Self::new_managed_authorized_with_gate(root, database, MaintenanceGate::default())
+    }
+
+    pub(crate) fn new_managed_authorized_with_gate(
+        root: &Path,
+        database: &Path,
+        maintenance: MaintenanceGate,
+    ) -> Result<Self, RepositoryPathError> {
         let mut service = Self::new(root)?;
         service.push_database = Some(database.to_owned());
         service.policy = Some(RepositoryPolicy::new(database));
+        service.maintenance = maintenance;
         Ok(service)
     }
 
@@ -214,6 +226,10 @@
 
     pub(crate) async fn push_permit(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
         self.push_jobs.clone().acquire_owned().await
+    }
+
+    pub(crate) async fn mutation_permit(&self) -> tokio::sync::OwnedRwLockReadGuard<()> {
+        self.maintenance.mutation_async().await
     }
 
     pub(crate) async fn blocking_permit(&self) -> Result<OwnedSemaphorePermit, AcquireError> {

src/http/mod.rs

Mode 100644100644; object 8ee1829275313cc16c7a0d2c

@@ -29,6 +29,7 @@
 use crate::domain::repository::validate_slug;
 use crate::feed_token::FeedTokenService;
 use crate::issue::IssueService;
+use crate::maintenance::MaintenanceGate;
 use crate::pull_request::PullRequestService;
 use crate::repository::{RepositoryService, RepositoryServiceError};
 use crate::search::MetadataSearchService;
@@ -127,7 +128,7 @@
         address: SocketAddr,
         config: PublicWebConfig,
     ) -> Result<Self, WebError> {
-        Self::start_public_inner(address, config, None, None).await
+        Self::start_public_inner(address, config, None, None, None).await
     }
 
     pub(crate) async fn start_public_with_key_reload(
@@ -135,8 +136,16 @@
         config: PublicWebConfig,
         key_reloader: AccountKeyReloader,
         readiness: ListenerReadiness,
+        maintenance: MaintenanceGate,
     ) -> Result<Self, WebError> {
-        Self::start_public_inner(address, config, Some(key_reloader), Some(readiness)).await
+        Self::start_public_inner(
+            address,
+            config,
+            Some(key_reloader),
+            Some(readiness),
+            Some(maintenance),
+        )
+        .await
     }
 
     async fn start_public_inner(
@@ -144,6 +153,7 @@
         config: PublicWebConfig,
         key_reloader: Option<AccountKeyReloader>,
         readiness: Option<ListenerReadiness>,
+        maintenance: Option<MaintenanceGate>,
     ) -> Result<Self, WebError> {
         let jobs = Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS));
         let database = config.instance_dir.join(crate::store::DATABASE_FILE);
@@ -153,9 +163,19 @@
         let secure_cookies = public_url.scheme() == "https";
         let login = WebLoginService::new(database, &public_url)?;
         let public = PublicWeb::open(config, Arc::clone(&jobs))?;
-        let repositories = RepositoryService::new(public.database(), public.repository_root());
+        let repositories = match maintenance.clone() {
+            Some(gate) => {
+                RepositoryService::new_with_gate(public.database(), public.repository_root(), gate)
+            }
+            None => RepositoryService::new(public.database(), public.repository_root()),
+        };
         let issues = IssueService::new(public.database());
-        let pull_requests = PullRequestService::new(public.database(), public.repository_root());
+        let pull_requests = match maintenance {
+            Some(gate) => {
+                PullRequestService::new_with_gate(public.database(), public.repository_root(), gate)
+            }
+            None => PullRequestService::new(public.database(), public.repository_root()),
+        };
         let feeds = FeedTokenService::new(public.database());
         let search = MetadataSearchService::new(public.database());
         let watches = WatchService::new(public.database());

src/main.rs

Mode 100644100644; object 6fca20235d5300df94cf7e7c

@@ -5,6 +5,7 @@
     reason = "the bootstrap command uses only part of the authentication API"
 )]
 mod auth;
+mod backup;
 mod bootstrap;
 mod cli;
 mod config;
@@ -18,6 +19,7 @@
 mod http;
 mod instance;
 mod issue;
+mod maintenance;
 mod markdown;
 mod policy;
 mod pull_request;
@@ -51,6 +53,10 @@
         }
     };
 
+    if let Some(Command::Restore { archive, target }) = &cli.command {
+        return run_restore(archive, target);
+    }
+
     match config::load(&cli) {
         Ok(config) => match cli.command {
             None => ExitCode::SUCCESS,
@@ -83,6 +89,10 @@
                     ExitCode::FAILURE
                 }
             },
+            Some(Command::Backup { output }) => run_backup(&config, &output).await,
+            Some(Command::Restore { .. }) => {
+                unreachable!("the restore command runs before configuration is loaded")
+            }
             Some(Command::Setup {
                 command:
                     SetupCommand::Admin {
@@ -118,6 +128,55 @@
             Some(Command::Admin {
                 command: AdminCommand::Audit { limit },
             }) => run_audit_command(&config.instance_dir, limit),
+        },
+        Err(error) => {
+            eprintln!("tit: {error}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+async fn run_backup(config: &config::Config, output: &std::path::Path) -> ExitCode {
+    let result = match backup::create_offline(&config.instance_dir, &config.config_path, output) {
+        Ok(()) => Ok(()),
+        Err(backup::BackupError::Instance(instance::InstanceError::Locked)) => {
+            control::request_backup(&config.instance_dir, output)
+                .await
+                .map_err(|error| Box::new(error) as Box<dyn std::error::Error>)
+        }
+        Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
+    };
+    match result {
+        Ok(()) => match writeln!(
+            io::stdout().lock(),
+            "Backup: {}\nWarning: This backup contains credentials.",
+            output.display()
+        ) {
+            Ok(()) => ExitCode::SUCCESS,
+            Err(error) => {
+                eprintln!("tit: cannot write backup information: {error}");
+                ExitCode::FAILURE
+            }
+        },
+        Err(error) => {
+            eprintln!("tit: {error}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+fn run_restore(archive: &std::path::Path, target: &std::path::Path) -> ExitCode {
+    match backup::restore(archive, target) {
+        Ok(()) => match writeln!(
+            io::stdout().lock(),
+            "Restore: {}\nThe restored instance is not active.",
+            target.display()
+        ) {
+            Ok(()) => ExitCode::SUCCESS,
+            Err(error) => {
+                eprintln!("tit: cannot write restore information: {error}");
+                ExitCode::FAILURE
+            }
         },
         Err(error) => {
             eprintln!("tit: {error}");

src/maintenance.rs

Mode 100644; object c085ad9b1d03

@@ -1,0 +1,31 @@
+use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
+
+use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock as AsyncRwLock};
+
+#[derive(Clone, Default)]
+pub(crate) struct MaintenanceGate {
+    synchronous: Arc<RwLock<()>>,
+    asynchronous: Arc<AsyncRwLock<()>>,
+}
+
+impl MaintenanceGate {
+    pub(crate) fn mutation(&self) -> RwLockReadGuard<'_, ()> {
+        self.synchronous
+            .read()
+            .unwrap_or_else(std::sync::PoisonError::into_inner)
+    }
+
+    pub(crate) async fn mutation_async(&self) -> OwnedRwLockReadGuard<()> {
+        self.asynchronous.clone().read_owned().await
+    }
+
+    pub(crate) fn maintenance(&self) -> RwLockWriteGuard<'_, ()> {
+        self.synchronous
+            .write()
+            .unwrap_or_else(std::sync::PoisonError::into_inner)
+    }
+
+    pub(crate) async fn maintenance_async(&self) -> OwnedRwLockWriteGuard<()> {
+        self.asynchronous.clone().write_owned().await
+    }
+}

src/pull_request.rs

Mode 100644100644; object f420256c97613925f75a7a87

@@ -13,6 +13,7 @@
     Comparison, ReadCancellation, ReadError, ReadLimits, RepositoryReadService,
 };
 use crate::git::repository::{GitRepository, GitRepositoryError};
+use crate::maintenance::MaintenanceGate;
 use crate::policy::{PolicyError, RepositoryPolicy};
 use crate::store::{
     GitOperationIntent, NewPullRequestMerge, NewPullRequestRefIntent, NewPullRequestReview,
@@ -29,6 +30,7 @@
     database: PathBuf,
     repositories: PathBuf,
     operations: Arc<Mutex<()>>,
+    maintenance: MaintenanceGate,
 }
 
 pub(crate) struct PullRequestComparison {
@@ -39,10 +41,19 @@
 
 impl PullRequestService {
     pub(crate) fn new(database: &Path, repositories: &Path) -> Self {
+        Self::new_with_gate(database, repositories, MaintenanceGate::default())
+    }
+
+    pub(crate) fn new_with_gate(
+        database: &Path,
+        repositories: &Path,
+        maintenance: MaintenanceGate,
+    ) -> Self {
         Self {
             database: database.to_owned(),
             repositories: repositories.to_owned(),
             operations: Arc::new(Mutex::new(())),
+            maintenance,
         }
     }
 
@@ -64,6 +75,7 @@
         validate_content(title, body)?;
         validate_branch(base_ref)?;
         validate_branch(head_ref)?;
+        let _maintenance = self.maintenance.mutation();
         let _operation = self
             .operations
             .lock()
@@ -116,6 +128,7 @@
         if number < 1 {
             return Err(PullRequestError::Number);
         }
+        let _maintenance = self.maintenance.mutation();
         let _operation = self
             .operations
             .lock()
@@ -184,6 +197,7 @@
         if number < 1 {
             return Err(PullRequestError::Number);
         }
+        let _maintenance = self.maintenance.mutation();
         let _operation = self
             .operations
             .lock()
@@ -210,6 +224,7 @@
         if number < 1 || revision.is_some_and(|number| number < 1) {
             return Err(PullRequestError::Number);
         }
+        let _maintenance = self.maintenance.mutation();
         let _operation = self
             .operations
             .lock()
@@ -261,6 +276,7 @@
             return Err(PullRequestError::Number);
         }
         validate_review(kind, body, path, side, line)?;
+        let _maintenance = self.maintenance.mutation();
         let _operation = self
             .operations
             .lock()
@@ -344,6 +360,7 @@
         if number < 1 || !matches!(method, "fast-forward" | "merge-commit") {
             return Err(PullRequestError::MergeMethod);
         }
+        let _maintenance = self.maintenance.mutation();
         let _operation = self
             .operations
             .lock()
@@ -473,6 +490,7 @@
         if let Some(actor) = actor {
             validate_username(actor)?;
         }
+        let _maintenance = self.maintenance.mutation();
         let _operation = self
             .operations
             .lock()
@@ -484,6 +502,7 @@
     }
 
     pub(crate) fn recover(&self) -> Result<(), PullRequestError> {
+        let _maintenance = self.maintenance.mutation();
         let _operation = self
             .operations
             .lock()

src/repository.rs

Mode 100644100644; object 36fd40620a73b17083f5db65

@@ -9,6 +9,7 @@
 use crate::auth::{AuthError, validate_username};
 use crate::domain::repository::{RepositoryNameError, validate_slug};
 use crate::git::repository::{GitRepository, GitRepositoryError};
+use crate::maintenance::MaintenanceGate;
 use crate::store::{
     NewAuditEvent, NewRepository, RepositoryOrigin, RepositoryRecord, Store, StoreError,
 };
@@ -17,13 +18,23 @@
 pub(crate) struct RepositoryService {
     database: PathBuf,
     root: PathBuf,
+    maintenance: MaintenanceGate,
 }
 
 impl RepositoryService {
     pub(crate) fn new(database: &Path, root: &Path) -> Self {
+        Self::new_with_gate(database, root, MaintenanceGate::default())
+    }
+
+    pub(crate) fn new_with_gate(
+        database: &Path,
+        root: &Path,
+        maintenance: MaintenanceGate,
+    ) -> Self {
         Self {
             database: database.to_owned(),
             root: root.to_owned(),
+            maintenance,
         }
     }
 
@@ -57,6 +68,7 @@
     ) -> Result<RepositoryRecord, RepositoryServiceError> {
         validate_username(owner)?;
         validate_slug(slug)?;
+        let _maintenance = self.maintenance.mutation();
         let object_format_name = object_format_name(object_format)?;
         let created_at = timestamp()?;
         let mut store = Store::open(&self.database)?;

src/serve.rs

Mode 100644100644; object b07a44df410fb162eeada8a0

@@ -10,11 +10,13 @@
 
 use crate::account::AccountService;
 use crate::auth::{AuthError, SshPublicKey};
+use crate::backup::OnlineBackupService;
 use crate::config::{Config, ConfigError};
 use crate::control::{ControlError, RunningControlServer};
 use crate::git::transport::{GitRepositories, RepositoryPathError};
 use crate::http::{ListenerReadiness, PublicWebConfig, RunningWebServer, WebError};
 use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
+use crate::maintenance::MaintenanceGate;
 use crate::policy::PolicyError;
 use crate::pull_request::{PullRequestError, PullRequestService};
 use crate::ssh::{AuthorizedSshKeys, RunningSshServer, SshServerError};
@@ -26,14 +28,26 @@
     let _lock = InstanceLock::acquire(&config.instance_dir)?;
     let database = prepare_database(&config.instance_dir)?;
     let repository_root = prepare_repository_root(&config.instance_dir)?;
-    PullRequestService::new(&database, &repository_root).recover()?;
+    let maintenance = MaintenanceGate::default();
+    PullRequestService::new_with_gate(&database, &repository_root, maintenance.clone())
+        .recover()?;
     let store = Store::open(&database)?;
     let keys = active_ssh_identities(&store)?;
-    let git = GitRepositories::new_managed_authorized(&repository_root, &database)?;
+    let git = GitRepositories::new_managed_authorized_with_gate(
+        &repository_root,
+        &database,
+        maintenance.clone(),
+    )?;
     drop(store);
 
     let accounts = AccountService::new(database);
-    let control = RunningControlServer::start(&config.instance_dir, accounts.clone())?;
+    let backup = OnlineBackupService::new(
+        config.instance_dir.clone(),
+        config.config_path.clone(),
+        maintenance.clone(),
+    );
+    let control =
+        RunningControlServer::start_with_backup(&config.instance_dir, accounts.clone(), backup)?;
     let authorized_keys = AuthorizedSshKeys::for_accounts(keys);
     let readiness = ListenerReadiness::default();
 
@@ -57,6 +71,7 @@
         },
         reload_keys,
         readiness.clone(),
+        maintenance,
     )
     .await?;
     let ssh = match RunningSshServer::start_with_dynamic_keys(

src/ssh.rs

Mode 100644100644; object 689a7d65cb5f268ef49f624d

@@ -374,6 +374,7 @@
     commands_complete: bool,
     pack: tokio::fs::File,
     pack_bytes: u64,
+    maintenance: tokio::sync::OwnedRwLockReadGuard<()>,
 }
 
 impl Handler for SshSession {
@@ -623,6 +624,7 @@
                             repository,
                             identity,
                             public_key,
+                            maintenance,
                         } = *receive;
                         session.data(channel, advertisement)?;
                         let pack = tokio::fs::File::create(service.incoming_pack()).await;
@@ -641,6 +643,7 @@
                                         commands_complete: false,
                                         pack,
                                         pack_bytes: 0,
+                                        maintenance,
                                     })),
                                 );
                             }
@@ -1603,6 +1606,7 @@
                 let actor = identity.username.clone();
                 let public_key = self.authenticated_key.clone()?;
                 let uses_policy = repositories.uses_policy();
+                let maintenance = repositories.mutation_permit().await;
                 let permit = repositories.blocking_permit().await.ok()?;
                 tokio::task::spawn_blocking(move || {
                     let _permit = permit;
@@ -1626,6 +1630,7 @@
                             repository,
                             identity,
                             public_key,
+                            maintenance,
                         },
                     )))
                 })
@@ -1659,6 +1664,7 @@
     repository: String,
     identity: SshIdentity,
     public_key: PublicKey,
+    maintenance: tokio::sync::OwnedRwLockReadGuard<()>,
 }
 
 async fn receive_data(git: &mut ReceiveChannel, data: &[u8]) -> Result<(), ()> {
@@ -1700,6 +1706,7 @@
         authorized_keys,
         commands,
         mut pack,
+        maintenance,
         ..
     } = *git;
     pack.flush().await.ok()?;
@@ -1711,6 +1718,7 @@
     tokio::task::spawn_blocking(move || {
         let _permit = permit;
         let _push_permit = push_permit;
+        let _maintenance = maintenance;
         if authorized_keys.identity(&public_key).as_ref() != Some(&identity)
             || !repositories.authorize(
                 &identity.username,

src/store/mod.rs

Mode 100644100644; object f5e41ee3917888fac9733a7f

@@ -2918,6 +2918,21 @@
             .map_err(Into::into)
     }
 
+    pub(crate) fn all_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
+        let mut statement = self.connection.prepare(
+            "SELECT repository.id, account.username, repository.slug,
+                    repository.visibility, repository.state, repository.object_format,
+                    repository.created_at, repository.archived_at
+             FROM repository
+             JOIN account ON account.id = repository.owner_account_id
+             ORDER BY account.username, repository.slug",
+        )?;
+        statement
+            .query_map([], repository_from_row)?
+            .collect::<Result<Vec<_>, _>>()
+            .map_err(Into::into)
+    }
+
     #[allow(
         dead_code,
         reason = "some integration tests import the store without public HTTP routes"

tests/git_http.rs

Mode 100644100644; object 1963f58da3b1447e0a1859e2

@@ -1,5 +1,8 @@
 #[path = "../src/git/http.rs"]
 mod http;
+#[allow(dead_code, reason = "the HTTP test does not run maintenance")]
+#[path = "../src/maintenance.rs"]
+mod maintenance;
 #[allow(
     dead_code,
     reason = "the HTTP test does not use each shared protocol API"

tests/git_push_ssh.rs

Mode 100644100644; object 4faed4cd492e6a14ee0ce247

@@ -16,6 +16,9 @@
 #[allow(dead_code, reason = "the SSH push test does not use issue commands")]
 #[path = "../src/issue.rs"]
 mod issue;
+#[allow(dead_code, reason = "the SSH push test does not run maintenance")]
+#[path = "../src/maintenance.rs"]
+mod maintenance;
 #[allow(dead_code, reason = "the SSH push test does not use repository policy")]
 #[path = "../src/policy.rs"]
 mod policy;

tests/git_ssh.rs

Mode 100644100644; object df9617f1373112b069b25e00

@@ -16,6 +16,9 @@
 #[allow(dead_code, reason = "the SSH Git test does not use issue commands")]
 #[path = "../src/issue.rs"]
 mod issue;
+#[allow(dead_code, reason = "the SSH Git test does not run maintenance")]
+#[path = "../src/maintenance.rs"]
+mod maintenance;
 #[allow(dead_code, reason = "the SSH Git test does not use repository policy")]
 #[path = "../src/policy.rs"]
 mod policy;

tests/public_routes.rs

Mode 100644100644; object e188a92e22aeaa8a0814df7f

@@ -37,6 +37,9 @@
 #[allow(dead_code, reason = "the public-route test does not mutate issues")]
 #[path = "../src/issue.rs"]
 mod issue;
+#[allow(dead_code, reason = "the public-route test does not run maintenance")]
+#[path = "../src/maintenance.rs"]
+mod maintenance;
 #[path = "../src/markdown.rs"]
 mod markdown;
 #[allow(dead_code, reason = "the public-route test uses anonymous policy only")]

tests/pull_requests.rs

Mode 100644100644; object 8c59c164382a3bc4e495a349

@@ -12,6 +12,9 @@
 )]
 #[path = "../src/git/mod.rs"]
 mod git;
+#[allow(dead_code, reason = "the pull-request test does not run maintenance")]
+#[path = "../src/maintenance.rs"]
+mod maintenance;
 #[allow(
     dead_code,
     reason = "the pull-request test uses repository policy through Git"

tests/serve.rs

Mode 100644100644; object 98d7ceb690d0197efce11042

@@ -77,6 +77,73 @@
     assert!(health.starts_with("HTTP/1.1 200"));
     assert!(health.ends_with("\r\n\r\nready\n"));
 
+    let backup_directory = TempDir::new().expect("create a backup directory");
+    let backup = backup_directory.path().join("instance.tar");
+    let backup_output = Command::new(env!("CARGO_BIN_EXE_tit"))
+        .args([
+            "--config",
+            config.to_str().expect("a UTF-8 configuration path"),
+            "backup",
+            backup.to_str().expect("a UTF-8 backup path"),
+        ])
+        .output()
+        .expect("request an online backup");
+    assert!(
+        backup_output.status.success(),
+        "online backup failed: {}",
+        String::from_utf8_lossy(&backup_output.stderr)
+    );
+    assert!(String::from_utf8_lossy(&backup_output.stdout).contains("contains credentials"));
+    assert_eq!(
+        fs::metadata(&backup)
+            .expect("inspect the backup")
+            .permissions()
+            .mode()
+            & 0o777,
+        0o600
+    );
+
+    let restored = TempDir::new().expect("create a restore target");
+    fs::set_permissions(restored.path(), fs::Permissions::from_mode(0o700))
+        .expect("make the restore target private");
+    let restore_output = Command::new(env!("CARGO_BIN_EXE_tit"))
+        .args([
+            "restore",
+            backup.to_str().expect("a UTF-8 backup path"),
+            restored.path().to_str().expect("a UTF-8 restore path"),
+        ])
+        .output()
+        .expect("restore the online backup");
+    assert!(
+        restore_output.status.success(),
+        "restore failed: {}",
+        String::from_utf8_lossy(&restore_output.stderr)
+    );
+    assert!(String::from_utf8_lossy(&restore_output.stdout).contains("is not active"));
+    let restored_database = rusqlite::Connection::open(restored.path().join("tit.sqlite3"))
+        .expect("open the restored database");
+    let restored_repository: String = restored_database
+        .query_row(
+            "SELECT id FROM repository WHERE slug = 'example'",
+            [],
+            |row| row.get(0),
+        )
+        .expect("read the restored repository");
+    drop(restored_database);
+    let restored_readme = Command::new("git")
+        .args(["--git-dir"])
+        .arg(
+            restored
+                .path()
+                .join("repositories")
+                .join(format!("{restored_repository}.git")),
+        )
+        .args(["show", "main:README.md"])
+        .output()
+        .expect("read the restored Git repository");
+    assert!(restored_readme.status.success());
+    assert_eq!(restored_readme.stdout, b"serve fixture\n");
+
     let second = Command::new(env!("CARGO_BIN_EXE_tit"))
         .args([
             "--config",

tests/ssh.rs

Mode 100644100644; object 8fc2de34b89367a71e186b46

@@ -16,6 +16,9 @@
 )]
 #[path = "../src/issue.rs"]
 mod issue;
+#[allow(dead_code, reason = "the SSH identity test does not run maintenance")]
+#[path = "../src/maintenance.rs"]
+mod maintenance;
 #[allow(
     dead_code,
     reason = "the SSH identity test does not use repository policy"

tests/web_shell.rs

Mode 100644100644; object 531a1c4832d3c4ab206cf7a2

@@ -35,6 +35,9 @@
 #[allow(dead_code, reason = "the Web shell test does not use issue workflows")]
 #[path = "../src/issue.rs"]
 mod issue;
+#[allow(dead_code, reason = "the Web shell test does not run maintenance")]
+#[path = "../src/maintenance.rs"]
+mod maintenance;
 #[path = "../src/markdown.rs"]
 mod markdown;
 #[allow(dead_code, reason = "the Web shell test has no repository catalog")]