michal/tit
Browse tree · Show commit · Download archive
Diff
22e9c51918dd → d8f7d7e1fb8f
Cargo.lock
Mode 100644 → 100644; object b45f583ccd10 → 9c7c166169c2
@@ -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 100644 → 100644; object f9c096c01da3 → 1b8335f1fe40
@@ -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 100644 → 100644; object 1d3dc9bf5269 → 99a2476b814e
@@ -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(¤t) {
+ Ok(()) => fs::set_permissions(¤t, 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 100644 → 100644; object d07e501a78ef → 6077cfd94df9
@@ -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 100644 → 100644; object 855737c28376 → 1bdbb2c0b55c
@@ -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 100644 → 100644; object 23a2bef622ba → 583aac731c2a
@@ -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 100644 → 100644; object adb8b5fff196 → 00eea6aeefaf
@@ -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 100644 → 100644; object 76f3d3c757fa → d6e63d355631
@@ -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 100644 → 100644; object 8ee182927531 → 3cc16c7a0d2c
@@ -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 100644 → 100644; object 6fca20235d53 → 00df94cf7e7c
@@ -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 100644 → 100644; object f420256c9761 → 3925f75a7a87
@@ -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 100644 → 100644; object 36fd40620a73 → b17083f5db65
@@ -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 100644 → 100644; object b07a44df410f → b162eeada8a0
@@ -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 100644 → 100644; object 689a7d65cb5f → 268ef49f624d
@@ -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 100644 → 100644; object f5e41ee39178 → 88fac9733a7f
@@ -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 100644 → 100644; object 1963f58da3b1 → 447e0a1859e2
@@ -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 100644 → 100644; object 4faed4cd492e → 6a14ee0ce247
@@ -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 100644 → 100644; object df9617f13731 → 12b069b25e00
@@ -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 100644 → 100644; object e188a92e22ae → aa8a0814df7f
@@ -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 100644 → 100644; object 8c59c164382a → 3bc4e495a349
@@ -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 100644 → 100644; object 98d7ceb690d0 → 197efce11042
@@ -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 100644 → 100644; object 8fc2de34b893 → 67a71e186b46
@@ -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 100644 → 100644; object 531a1c4832d3 → c4ab206cf7a2
@@ -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")]